React vs Svelte 開發體驗:狀態管理

狀態管理是在處理資料時一個不可或缺的核心:React 採用的是 useState Hook,而 Svelte 則是直接使用變數,使用起來更直觀。我們將透過範例來了解兩者在開發體驗上的差異。

React 狀態管理:使用 useState Hook

React 讓元件可以擁有內部狀態的方式是透過 useState Hook 來完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useState } from 'react';

function Counter() {
const [count, setCount] = useState(0);

return (
<button onClick={() => setCount(count + 1)}>
Counter:{count}
</button>
);
}

export default Counter;
  • useState(0):初始化 count 為 0。
  • setCount:更新 count 的函式,每次按鈕點擊讓值加 1。
  • 透過 React 的 Virtual DOM,變化的值會觸發元件重新渲染。

非同步更新注意事項

React 的狀態更新是非同步的,因此如果在同一函式中連續呼叫多次 setCount(count + 1),可能無法即時反映最新的狀態。建議改用以下方式來避免此問題:

1
setCount(prevCount => prevCount + 1);

這樣可以保證邏輯永遠是基於最新狀態更新。

Svelte 狀態管理:響應式變數

Svelte 採用一種更直覺的狀態處理方式。它不需要額外的 Hook,直接使用變數即可自動觸發畫面更新。

1
2
3
4
5
6
7
<script>
let count = 0;
</script>

<button on:click={() => count++}>
Counter:{count}
</button>
  • 使用 let count = 0 宣告變數。
  • 直接對 count 進行操作,例如 count++,Svelte 就能自動感知變化並更新 DOM。
  • 這是透過 Svelte 的 編譯時響應式系統 (reactivity system) 實現的,完全不需要 Virtual DOM。

Note: React.js 使用 Virtual DOM 來追蹤是否元件需要被更新;而 Svelte 沒有使用 Virtual DOM 而是直些更新在 DOM 上。

React vs Svelte 狀態更新差異

功能面向 React Svelte
語法複雜度 使用 Hook,語法稍多 直覺操作變數,語法簡潔
DOM 更新機制 Virtual DOM 比對再更新 編譯階段生成 DOM 操作
狀態更新方式 必須使用 setState / setCount 直接修改變數
更新非同步處理 是,需注意資料一致性 否,變數改變即更新
可讀性與維護性 中等

如果追求極簡語法與優化效能,Svelte 提供了非常流暢的開發體驗。而 React 則擁有強大的生態系與社群支持。

延伸閱讀

使用 Node.js 監控資料夾變更,並以 Event Emitter 封裝 File Watcher

Node.js 監控資料夾

在 Node.js 中,我們可以透過 fs.watch 來監控資料夾是否有異動,不需要寫太多程式碼。以下是一個基本範例:

1
2
3
4
5
6
7
8
9
10
11
const fs = require('fs');
const path = require('path');
const directoryToWatch = './watched';

fs.watch(directoryToWatch, (eventType, filename) => {
if (filename) {
console.log(`File ${filename} has changed with event type: ${eventType}`);
} else {
console.log('filename not provided');
}
});

這段程式會持續監控 ./watched 資料夾的內容變化,當有新增、修改或刪除檔案時,就會觸發 callback 函式。

根據副檔名處理不同邏輯

如果我們想要針對不同的副檔名做不同處理,可以進一步使用 path.extname() 判斷檔案類型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const fs = require('fs');
const path = require('path');
const directoryToWatch = './watched';

fs.watch(directoryToWatch, (eventType, filename) => {
if (filename) {
const fileExtension = path.extname(filename);
switch (fileExtension) {
case '.js':
// ...
break;
case '.txt':
// ...
break;
case '.json':
// ...
break;
default:
console.log(`Ignoring file ${filename} with extension ${fileExtension}`);
}
} else {
console.log('filename not provided');
}
});

不過隨著專案變大,副檔名的判斷邏輯(if-else 或 switch)會越來越複雜、難以維護。這時就可以考慮封裝成 Event Driven 的架構,提升可讀性與擴充性。

將邏輯封裝成 Pub/Sub 模型的 Watcher

Node.js 提供的 EventEmitter 非常適合實作發布/訂閱(Publish/Subscribe)模型。EventEmitter 詳細介紹可以參考這篇文章:

使用 Node.js 的 EventEmitter 建立事件監聽機制:概念 & 實作

fs.watch 偵測到檔案更動,我們使用 .emit(extension) 來「發布」這個事件,而訂閱該副檔名的使用者透過 .on(extension, callback) 就能被「通知」。這讓我們可以針對特定副檔名建立模組化的監聽邏輯,使架構更清晰、易於擴充。

我們利用 class 繼承 EventEmitter,在 constructor 參數傳入資料夾,並呼叫 start() 時開始監控:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// watcher.js
const fs = require('fs');
const path = require('path');
const events = require('events');

class Watcher extends events.EventEmitter {
constructor(watchDir) {
super();
this.watchDir = watchDir;
}

start() {
fs.watch(this.watchDir, (eventType, filename) => {
if (!filename) {
console.warn('Warning: filename not provided');
return;
}
const fileExtension = path.extname(filename);
this.emit(fileExtension, eventType, filename);
})
}
}

module.exports = Watcher;

這段程式碼會根據檔案副檔名觸發對應的事件,達成根據檔案類型處理相對應的 callback。我們建立一個 watcher 並註冊感興趣的副檔名,並呼叫 start() 開始監控的資料夾:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// index.js
const Watcher = require('./watcher');

const directoryToWatch = './watched';
const watcher = new Watcher(directoryToWatch);

watcher.on('.js', (eventType, filename) => {
// ...
});

watcher.on('.txt', (eventType, filename) => {
// ...
});

watcher.on('.json', (eventType, filename) => {
// ...
});

watcher.start();

這樣的寫法使得每個副檔名的處理邏輯可以被模組化,也可以讓多個檔案處理者獨立訂閱自己關心的事件。

Conclusion

透過封裝 fs.watch 並結合 EventEmitter 的事件機制,我們實作了一個簡單且可擴充的資料夾監控系統。這個模式讓你可以專注在各類檔案的邏輯處理,而不需要在程式碼中寫太複雜的 if-else 部分。這樣的架構能讓你在不干擾其他邏輯的情況下,自由地新增更多檔案類型處理器,提升模組化與維護性。

Reference

使用 Node.js 的 EventEmitter 建立事件監聽機制:概念 & 實作

概念

在 Node.js 中,建立 EventListener 可以使用 EventEmitter,概念與 publish/subscribe 非常相似,如果之前曾經使用過 Redis 的 Pub/Sub 機制,會更容易理解。

假設有一個人先訂閱(subscribe)了一個名為 ‘xxx’ 的頻道(channel),之後當有人發布(publish)訊息到 ‘xxx’ 頻道時,訂閱者就會看到發布者發出的訊息。

Event Listener

回到 Node.js 的 Event Listener 機制,我們可以透過 EventEmitter 建立一個 publish/subscribe 物件。執行物件的 .on 方法就類似於訂閱(subscribe),而 .emit 方法則類似於發布(publish)。以下是範例程式碼:

1
2
3
4
5
6
7
8
9
10
const EventEmitter = require('events');
const channel = new EventEmitter();

// 訂閱 'join' 事件:任何人加入時會通知
channel.on('join', (message) => {
console.log(`Received message: ${message}`);
});

// 發布 'join' 事件
channel.emit('join', 'Hello, everyone!');

在這個範例中,我們使用 EventEmitter 建立了一個 channel 並且監聽 ‘join’ 事件。當有使用者加入時,會透過 .emit(‘join’) 通知 ‘join’ 事件。我們可以建立一個 HTTP server 並稍微修改一下來驗證我們的想法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// server.js
const net = require('net');

const EventEmitter = require('events').EventEmitter;
const channel = new EventEmitter;

channel.clients = {};

// 訂閱 'join' 事件:任何人加入時會通知
channel.on('join', (id, client) => {
channel.clients[id] = client;
console.log(`${id} join`);
});

const server = net.createServer(client => {
// 使用 `IP:Port` 當作 ID
const id = `${client.remoteAddress}:${client.remotePort}`;

// 通知 'join' 事件
channel.emit('join', id, client);
})

server.listen(30000);

.emit 不僅用來觸發事件,還可以同時傳入多個參,當你呼叫 .emit('join', arg1, arg2, ...) 時,所有註冊了這個事件的監聽器 .on('join', (arg1, arg2) => {...}) 都會依序被呼叫,且能接收到這些參數。

net.createServer 是使用 Node.js 的 net module 來建立一個 TCP server。當有 client 進來時會執行 callback function,這個 callback 第一個參數代表接收到一個 net.Socket 的物件。

在 socket 的 callback 中,可以透過 client.remoteAddressclient.remotePort 來獲取連線 client 的 IP 與 port,組合成唯一個 id。然後使用 .emit('join', id, client) 觸發一個 join 事件,同時把這個 idclient 傳入給 join 事件的 callback,最後儲存在 channel.clients 中。

我們來測試一下,先開一個 server,然後使用 telnet localhost 30000 來連到 server,在 server 上可以看到 xxx join 的訊息。

1
2
3
4
5
6
7
8
9
# 先開一個 server
$ node server.js
::ffff:127.0.0.1:37282 join

# 然後加入使用者
$ telnet localhost 30000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.

Error Event

在前面的例子,這個 join 事件名稱是可以自定義的,可以是任何字串,所以如果觸發一個不存在的事件 .emit('notExist'),其實是不會發生什麼事情的,但是有一個例外:error 事件。如果沒有人註冊 error 事件,但有人在 error 事件中發出訊息,程式會直接停止運行並印出錯誤訊息。

1
2
3
4
5
6
const events = require('events');
const myEmitter = new events.EventEmitter();
myEmitter.on('error', err => {
console.log(`ERROR: ${err.message}`);
});
myEmitter.emit('error', new Error('Something is wrong.'));

如果在執行 error callback 時又發生錯誤,則會丟出 uncaughtException 錯誤,可以透過 process.on('uncaughtException') 來捕捉這個錯誤。

1
2
3
process.on('uncaughtException', err => {
console.error('There was an uncaught error', err);
});

上述的例子可以增加這兩個事件:

1
2
3
4
5
6
7
process.on('uncaughtException', err => {
console.log("uncaughtException", err.stack);
});

channel.on('error', err => {
console.log("channel error", err.message)
});

廣播給所有使用者

現在 server 建立好了,我們想要讓多個使用者加入,如果其中有一個 client 發話,其他 client 都可以收到訊息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const net = require('net');

const EventEmitter = require('events').EventEmitter;
const channel = new EventEmitter;

channel.clients = {};

channel.on('join', (id, client) => {
channel.clients[id] = client;
channel.on('broadcast', (senderId, message) => {
if (id != senderId) {
channel.clients[id].write(message);
}
});
console.log(`${id} join`);
channel.emit('broadcast', id, `${id} join\n`); // 通知有人加入群組
});

const server = net.createServer(client => {
const id = `${client.remoteAddress}:${client.remotePort}`;
channel.emit('join', id, client);
client.on('data', data => {
data = data.toString();
channel.emit('broadcast', id, data);
});
})

server.listen(30000);

join 事件中,當有 client 加入時,會針對每個 client 註冊 broadcast 事件。當有人發送廣播訊息時 channel.emit('broadcast', ...),就會觸發 callback 事件。這個 callback 需要接收兩個參數:

  • senderId:發送者的 id。
  • message:廣播的訊息。

如果該 client 不是訊息的發送者,則會使用 channel.clients[id].write(message) 將訊息送給這個 client。

最後每當 client 連線到 server 並傳送資料時,在 data 事件中 client.on('data', callback),這個 callback 就會被觸發。這個 callback 會透過我們前面新增的 broadcast 事件來廣播給所有人。

.on('data', callback) 收到的資料通常是 Buffer,所以會先轉成 string。

我們來測試一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# server
$ node server.js
::ffff:127.0.0.1:40542 join
::ffff:127.0.0.1:40556 join
::ffff:127.0.0.1:40572 join

# user1 再另外開三個 Terminal 分別建立連線
$ telnet localhost 30000
::ffff:127.0.0.1:40556 join
::ffff:127.0.0.1:40572 join
akiicat

# user2
$ telnet localhost 30000
::ffff:127.0.0.1:40572 join
akiicat

# user3
$ telnet localhost 30000
akiicat

先開一個 server,然後分別加入使用者並發話,記得最後要按 enter 才會發送,就可以看到發送的訊息會廣播到其他連線的 client。

如果你持續增加使用者,超過 10 個人訂閱同一個事件時,Node.js 會印出警告你,但可以透過 .setMaxListeners(50) 來增加上限。

1
eventEmitter.setMaxListeners(50);

離開群組 removeListener

現在我們的使用者可以加入群組,並且可以廣播訊息給所有人,看起來很不錯,但是使用者加入後卻沒有辦法離開。我們希望使用者輸入 leave 的時候,會將自己從群組中移除,並且斷開連線。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
channel.on('leave', (id) => {
channel.emit('broadcast', id, `${id} has left\n`); // 通知有人離開
channel.clients[id].destroy(); // 斷開連線
delete channel.clients[id];
});

const server = net.createServer(client => {
const id = `${client.remoteAddress}:${client.remotePort}`;
channel.emit('join', id, client);
client.on('data', data => { data = data.toString();
if (data.trim() === 'leave') { // 如果使用者輸入 leave 則觸發 leave 事件
channel.emit('leave', id);
} else {
channel.emit('broadcast', id, data);
}
});
})

看起來功能已經完成了,但是有一個小問題。在 join 事件中,使用者訂閱的 broadcast 事件沒有清除。如果有人離開之後,就會在後續的廣播嘗試寫入不存在的連線,這會造成 Node.js 的錯誤。

因此我們在 leave 時需要用 .removeListener 來取消訂閱,注意 removeListener 帶入的 callback 必須跟 on 的 callback 一樣,

1
2
3
4
// removeListener 帶入的 callback 需要跟 on 的 callback 一樣
const callback = () => {...}
.on('broadcast', callback)
.removeListener('broadcast', callback)

所以我們需要修改一下 server.js

  • 新增 channel.subscriptions,並在 join 事件中,每當有使用者加入時儲存每個 client 的 callback。
  • leave 事件中 removeListener 將對應使用者的 broadcast callback 移除。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const net = require('net');

const EventEmitter = require('events').EventEmitter;
const channel = new EventEmitter;

channel.clients = {};
channel.subscriptions = {}; // 新增 subscriptions 物件用來儲存 client 的 broadcast callback

channel.on('join', (id, client) => {
channel.clients[id] = client;
channel.subscriptions[id] = (senderId, message) => { // 儲存 client 的 broadcast callback
if (id != senderId) {
channel.clients[id].write(message);
}
};
channel.on('broadcast', channel.subscriptions[id]); // client 訂閱 broadcast 事件
channel.emit('broadcast', id, `${id} join\n`); // client 訂閱 broadcast 事件
});

channel.on('leave', (id) => {
channel.removeListener('broadcast', channel.subscriptions[id]); // client 取消訂閱 broadcast 事件
channel.emit('broadcast', id, `${id} has left\n`);
channel.clients[id].destroy();
delete channel.clients[id];
delete channel.subscriptions[id]; // 從物件中移除
});

const server = net.createServer(client => {
const id = `${client.remoteAddress}:${client.remotePort}`;
channel.emit('join', id, client);
client.on('data', data => { data = data.toString();
if (data.trim() === 'leave') {
channel.emit('leave', id);
} else {
channel.emit('broadcast', id, data);
}
});
})

server.listen(30000);

在 Terminal 測試使用者能否正常離開群組:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ node server.js

# user1 使用者離開
$ telnet localhost 30000
::ffff:127.0.0.1:54538 join
::ffff:127.0.0.1:54554 join
Hello
leave
Connection closed by foreign host.

# user2 使用者離開後照樣可正常廣播
$ telnet localhost 30000
::ffff:127.0.0.1:54554 join
Hello
::ffff:127.0.0.1:54528 has left
Hi

# user3
$ telnet localhost 30000
Hello
::ffff:127.0.0.1:54528 has left
Hi

關閉伺服器 removeAllListeners

現在我們新增一個新功能,輸入 shutdown 指令讓所有人取消訂閱,並斷開所有連線。在前一章節所學的 removeListener,我們可以使用迴圈將每個使用者從 broadcast 事件移除。然而,我們可以更間單的使用 .removeAllListeners('broadcast') ,來一次性移除所有訂閱 broadcast 事件上的使用者。最後我們的完整程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const net = require('net');

const EventEmitter = require('events').EventEmitter;
const channel = new EventEmitter;

channel.clients = {};
channel.subscriptions = {}; // 新增 subscriptions 物件用來儲存 client 的 broadcast callback

channel.on('join', (id, client) => {
channel.clients[id] = client;
channel.subscriptions[id] = (senderId, message) => { // 儲存 client 的 broadcast callback
if (id != senderId) {
channel.clients[id].write(message);
}
};
channel.on('broadcast', channel.subscriptions[id]); // client 訂閱 broadcast 事件
channel.emit('broadcast', id, `${id} join\n`); // client 訂閱 broadcast 事件
});

channel.on('leave', (id) => {
channel.removeListener('broadcast', channel.subscriptions[id]); // client 取消訂閱 broadcast 事件
channel.emit('broadcast', id, `${id} has left\n`);
channel.clients[id].destroy();
delete channel.clients[id];
delete channel.subscriptions[id]; // 從物件中移除
});

channel.on('shutdown', () => {
channel.emit('broadcast', '', 'Server shutdown');
channel.removeAllListeners('broadcast');
Object.keys(channel.clients).forEach(function(id) { channel.clients[id].destroy();
delete channel.clients[id];
delete channel.subscriptions[id];
})
});

const server = net.createServer(client => {
const id = `${client.remoteAddress}:${client.remotePort}`;
channel.emit('join', id, client);
client.on('data', data => { data = data.toString();
if (data.trim() === 'shutdown') {
channel.emit('shutdown', id);
} else if (data.trim() === 'leave') {
channel.emit('leave', id);
} else {
channel.emit('broadcast', id, data);
}
});
})

server.listen(30000);

測試一下,輸入 shutdown 後所有人將會斷開連線:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# server
$ node server.js

# user1
$ telnet localhost 30000
::ffff:127.0.0.1:51130 join
::ffff:127.0.0.1:51136 join
shutdown
Server shutdownConnection closed by foreign host.

# user2
$ telnet localhost 30000
::ffff:127.0.0.1:51136 join
Server shutdownConnection closed by foreign host.

# user3
$ telnet localhost 30000
Server shutdownConnection closed by foreign host.

只要 Event Loop 中還有待處理的非同步操作(例如未完成的 I/O、定時器、網絡請求等),Node.js process 就不會自動退出。這對於需要長期運行、持續監聽請求的 Web 伺服器來說是理想的行為;而對於命令行工具或一次性任務,則可能需要在所有非同步操作完成後主動退出。

Reference

Node.js Event Loop 是如何運作的

Node.js 是 Single Thread 的環境執行,是如何透過 Event Loop 的方式來實現 Non-blocking I/O 的操作。

Event Loop 運作方式

想像一個 HTTP Request 進入 Node.js http.Server 時,Event Loop 會觸發對應的 callback。假設該 callback 需要從資料庫拿到使用者資料,並從磁碟讀取電子郵件 template,然後將使用者資料填入 template 後,回傳 HTTP Response。

Nodejs Event Loop

在這個過程中,Event Loop 會以先進先出 (FIFO) 的方式運行 Event Queue:

  1. 首先,Event Loop 包含一個 Timer,可以透過 setTimeout 或 setInterval 設定。
  2. 接下來,如果 Timeout 或是有新的 I/O Event 進來,則會進入 poll phase (輪詢階段),在此階段會安排執行 (schedule) 對應的 callback。

Event Loop 的概念不會太複雜,有 Event 進來,執行相對應的 callback。

那如果正在執行 poll phase 時,又有新的 I/O Event,Node.js 則會透過 setImmediate 設定,在當前 callback 執行完後立刻執行新進來的 I/O Event callback。

Library

Node.js 的 Non-blocking I/O Opertaion 是透過 libuv 實現的,libuv 實現 Node.js 的 Event Loop、Non-blocking I/O、網路、磁碟、檔案系統等功能,位於下圖的左下角:

Nodejs Library

原先在 Node.js 程式碼是透過 C++ Binding 綁定到 libuv 或其他 library 上,這些 library 會去跟 OS 來溝通。

之後 Google 的 V8 JavaScript 引擎的出現,讓 Node 更加強大。V8 引擎厲害之處就是直接繞過 C++ binding 變成 machine code,這大幅的提升了執行效率,如上圖右半側。

Reference

安裝 Windows 11 虛擬機過程與問題

很久沒安裝 Windows 11 VM 了,原本想說應該很順利,結果裝了一整個晚上,沒想到會踩了一堆雷,所以這篇來紀錄一下安裝 Windows 11 VM 的過程:

讀取 ISO

在 Windows 11 開機的時候,若出現以下提示文字:

Press any key to boot from CD or DVD…

vm-windows-11-installation-1.png

此時,一定要按下鍵盤上的任意鍵,如果只用滑鼠輸入會失敗。

繞過 TPM 檢查

在安裝 Windows 11 時,系統會檢查硬體是否符合規格。如果是在虛擬機上安裝,得需要手動關閉 TPM 的檢查:

  1. 啟動命令提示字元:在安裝畫面按下 Shift + F10,這將開啟命令提示字元 (cmd)。
  2. 開啟登錄編輯器:在命令提示字元中輸入 regedit 並按下 Enter 鍵。
  3. 新增 LabConfig 機碼
  • 找到 HKEY_LOCAL_MACHINE\SYSTEM\Setup
  • 在此路徑下新增一個名為 LabConfig 的資料夾(機碼)。
  1. 繞過檢查的設定:在 HKEY_LOCAL_MACHINE\SYSTEM\Setup\LabConfig 機碼內新增以下三個 DWORD 32-bit 項目
  • BypassTPMCheck,值設為 1。
  • BypassRAMCheck,值設為 1。
  • BypassSecureBootCheck,值設為 1。

vm-windows-11-installation-2.png

完成這些設定後,關閉登錄編輯器,繼續安裝,即可成功繞過檢查。

帳號登入

在安裝過程的最後階段,Windows 可能會要求輸入帳號密碼,目前還不知道要略過此步驟。

建議

安裝完成後,建議馬上建立一個快照 (snapshot),這樣如果日後需要重置或修復系統,就不必重新安裝。

Reference

何時該使用 React Redux

今天這章會討論什麼時候要把資料放在 Redux 裡面,什麼時候要將資料放在 React 的 components 裡。

React 將 components 分成 Presentational Components (展示組件) 與 Container Components (容器組件)。

Presentational Components

Presentational Components 主要負責 UI 的部分,通常不會有複雜 application 狀態管理的資料,通過會由 props 傳入無狀態 (stateless) 的資料,像是:

  • 跟 UI 相關的資料 e.g. 顏色的使用
  • 只有在必要時才擁有自己的狀態 e.g. 下拉式選單的狀態
  • 需要手動建立新的東西 e.g. new post 的資料儲存

Container Components

Container Components 主要負責 application 的狀態,通常會使用 Redux 來管理,然後在 render 的時候會資料傳給 Presentational Components,通常會儲存有狀態的資料:

  • application data flow
  • 使用者相關的資訊 e.g. 最喜歡的顏色

Compare

特性 Presentational Container
主要用途 UI render application state
狀態 無狀態或是透過 props 獲得狀態 通常有狀態,透過 Redux 管理
類別 smart dumb
儲存類型 儲存複雜的東西 儲存簡單的東西

Reference

React Redux Example App 範例程式碼

前一篇介紹 Redux 是參考 Flux 的架構而設計的,這篇要用 Redux 來寫一個簡單的 React Counter App。

在 Ubuntu 18.04 上安裝 Docker CE

再提醒一下,React 跟 Redux 的差別是:React 是一個 front-end library;Redux 是一個架構,可以不用跟 React 一起使用,也可以跟 Vue 或 Angular 搭配。

Counter App

Install

建立一個 React App 然後安裝 Redux:

1
2
3
npx create-react-app counter-app
cd counter-app
npm install --save redux react-redux

Actions

建立 Redux 的 Action,Action 一定要回傳 type 讓 Reducer 去做對應的資料更新,Middleware 會在送到 Reducer 之前執行。

1
2
3
4
5
6
7
8
// actions.js
export const increment = () => ({
type: 'INCREMENT'
});

export const decrement = () => ({
type: 'DECREMENT'
});

Reducer

建立 Redux 的 Reducer,Reducer 不能修改原本的 state,必須複製一份,並且回傳最後 state 應該要的資料。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// reducer.js
const initialState = {
count: 0
};

const counterReducer = (state = initialState, action) => {
switch (action.type) {
case 'INCREMENT':
return {
...state,
count: state.count + 1
};
case 'DECREMENT':
return {
...state,
count: state.count - 1
};
default:
return state;
}
};

export default counterReducer;

Store

建立 Redux 的 Store,用來儲存 state 的資料

1
2
3
4
5
6
7
// store.js
import { createStore } from 'redux';
import counterReducer from './reducer';

const store = createStore(counterReducer);

export default store;

Counter

建立 React Counter Element,而且注意這邊需要將 Action 傳入 Dispatcher 來讓後續的 Reducer 來更新狀態。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Counter.js
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from './actions';

const Counter = () => {
const count = useSelector(state => state.count);
const dispatch = useDispatch();

return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => dispatch(increment())}>Increment</button>
<button onClick={() => dispatch(decrement())}>Decrement</button>
</div>
);
};

export default Counter;

App

最後 react-redux library 透過 Provider Component 來跟 React 串接,Provider 會提供 Store 的資料。

1
2
3
4
5
6
7
8
9
10
11
12
13
// App.js
import React from 'react';
import { Provider } from 'react-redux';
import store from './store';
import Counter from './Counter';

const App = () => (
<Provider store={store}>
<Counter />
</Provider>
);

export default App;

Reference

Flux 跟 Redux 之間的關係

Flux 和 Redux 都是用來管理 application 狀態的架構,差異是不同的設計理念和實做方式:

Flux

Flux 是 Facebook 提出的架構模式,用於解決複雜的 data flow 問題,Flux 的架構如下:

  1. Action: 描述 application 的事件或行為。
  2. Dispatcher: 分發 Action 給 Store。它是 Flux 架構中的中央樞紐。
  3. Store: 儲存 application 狀態和邏輯。每個 Store 負責 application 一部分狀態。
  4. View: 展示 application UI,並且可以根據 Store 的變化來更新。

Flux 的 data flow 是單向的,從 Action 到 Dispatcher,再到 Store,最後到 view。

Redux

Redux 參考並簡化 Flux 管理狀態概念,Redux 的架構如下:

  1. Action: 與 Flux 中的 Action 類似,用於描述 application 的事件或行為。
  2. Reducer: 是一個 function,負責根據 Action 來更新 application 的狀態。Redux 中沒有 Sispatcher,取而代之的是 Reducer。
  3. Store: 儲存 application 的狀態。Redux 中只有一個單一的 Store,與 Flux 有多個 Store 不同。而且 Redux 只能透過 Action 可以修改 Store 的資料。
  4. Middleware: 用於處理異步操作或其他事件 e.g. Error Handling。

Redux 的 data flow 也是單向的,並且強調使用 function 來更新狀態。

Redux 使用單一集中狀態的物件,並以特定的方式進行更新。當你想要更新狀態時(e.g click event),會創建一個 Action 並由某個 Reducer 處理。Reducer 會複製當前狀態,且使用 Action 中的資料進行修改,然後返回新的狀態。當 Store 更新時,可以監聽事件並更新

Redux 跟 React 的差別是:React 是一個 front-end library;Redux 是一個架構,可以不用跟 React 一起使用,也可以跟 Vue 或 Angular 搭配。這篇是一個 React Redux 的範例:

React Redux Example App 範例程式碼

Compare

  • Redux 使用單一的 store: 與 Flux 在中多個 Store 中定位狀態信息不同,Redux 將所有內容保存在一個地方。在 Flux 中,可以有許多不同的 store。Redux 打破了這一點,強制使用單一 global Store。
  • Redux 使用 reducers: Reducers 是以不更動原本資料的方式來更新資料。在 Redux 中,行為是以可預測的,因為所有的改動都要經過 Reducer,且每一次的改動只會更新一次 global Store。
  • Redux 使用 middleware: 由於 Action 和資料以單向方式流動,我們可以透過 Redux 增加 middleware,並在資料更新時加上客製化的行為 e.g. Log or Catch Error。
  • Redux decouple Action 與 Store: 建立 Action 時不會向通知 Store 任何東西,反而是回傳 Action 物件;Flux 的 Action 則會直接修改 Store。

Reference

修改 Kubernetes Kube Proxy IPVS scheduler mode

由於 kube-proxy 在 ipvs scheduler 的模式是 rr,這篇會教學如何在 kubernetes cluster 裡面改變 ipvs scheduler 的模式。

環境

目前有三台 node 所組成的 kubernetes cluster

1
2
3
4
5
# kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
node1 Ready master 5d7h v1.16.6 192.168.0.121 <none> Ubuntu 16.04.5 LTS 4.15.0-88-generic docker://18.9.7
node2 Ready master 5d7h v1.16.6 192.168.0.146 <none> Ubuntu 16.04.5 LTS 4.15.0-88-generic docker://18.9.7
node3 Ready <none> 5d7h v1.16.6 192.168.0.250 <none> Ubuntu 16.04.5 LTS 4.15.0-88-generic docker://18.9.7

Kubernetes 裡面有一個 hello world deployment,並使用 NodePort 暴露 8080 port 的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# kubectl get all
NAME READY STATUS RESTARTS AGE
pod/hello-868dc85486-8lvps 1/1 Running 0 3d2h
pod/hello-868dc85486-j2q7x 1/1 Running 0 3d2h
pod/hello-868dc85486-n2lvt 1/1 Running 0 3d2h
pod/hello-868dc85486-njcfn 1/1 Running 0 3d2h

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/kubernetes ClusterIP 10.233.0.1 <none> 443/TCP 5d8h
service/node-port NodePort 10.233.2.107 <none> 8080:31191/TCP 35h

NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/hello 4/4 4 4 3d2h

NAME DESIRED CURRENT READY AGE
replicaset.apps/hello-868dc85486 4 4 4 3d2h

使用 ipvsadm 查看 ipvs 的 scheduler 模式,目前的模式是 rr。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# ipvsadm -ln
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
-> RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 169.254.25.10:31191 rr
-> 10.233.90.16:8080 Masq 1 0 0
-> 10.233.92.16:8080 Masq 1 0 0
-> 10.233.92.17:8080 Masq 1 0 0
-> 10.233.96.12:8080 Masq 1 0 0
TCP 172.17.0.1:31191 rr
-> 10.233.90.16:8080 Masq 1 0 0
-> 10.233.92.16:8080 Masq 1 0 0
-> 10.233.92.17:8080 Masq 1 0 0
-> 10.233.96.12:8080 Masq 1 0 0
TCP 192.168.0.121:31191 rr
-> 10.233.90.16:8080 Masq 1 0 0
-> 10.233.92.16:8080 Masq 1 0 0
-> 10.233.92.17:8080 Masq 1 0 0
-> 10.233.96.12:8080 Masq 1 0 0
TCP 10.233.90.0:31191 rr
-> 10.233.90.16:8080 Masq 1 0 0
-> 10.233.92.16:8080 Masq 1 0 0
-> 10.233.92.17:8080 Masq 1 0 0
-> 10.233.96.12:8080 Masq 1 0 0
...

下一節將說明如何修改 ipvs scheduler 的方法。

方法

首先,IPVS scheduler 的設定是在 kube-proxy 的 ConfigMap 裡面。

1
kubectl edit cm kube-proxy -n kube-system

data.config.conf.ipvs.scheduler 裡的參數修改成其他的 scheduler 模式:

1
2
3
4
data:
config.conf:
ipvs:
scheduler: sh

Service: IPVS proxy mode 裡面有提供以下的 scheduler 的模式:

  • rr: round-robin
  • lc: least connection (smallest number of open connections)
  • dh: destination hashing
  • sh: source hashing
  • sed: shortest expected delay
  • nq: never queue

或參考 IPVS wiki

刪除原有的 kube-proxy 的 pod,daemonset.apps/kube-proxy 會自動重新建立新的 kube-proxy Pod。

1
kubectl delete -n kube-system $(kubectl get all -n kube-system | grep pod/kube-proxy | cut -d ' ' -f 1)

再次使用 ipvsadm 查看 ipvs 的 scheduler 就會是修改後的狀態。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# ipvsadm -ln
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
-> RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 169.254.25.10:31191 sh
-> 10.233.90.16:8080 Masq 1 0 0
-> 10.233.92.16:8080 Masq 1 0 0
-> 10.233.92.17:8080 Masq 1 0 0
-> 10.233.96.12:8080 Masq 1 0 0
TCP 172.17.0.1:31191 sh
-> 10.233.90.16:8080 Masq 1 0 0
-> 10.233.92.16:8080 Masq 1 0 0
-> 10.233.92.17:8080 Masq 1 0 0
-> 10.233.96.12:8080 Masq 1 0 0
TCP 192.168.0.121:31191 sh
-> 10.233.90.16:8080 Masq 1 0 0
-> 10.233.92.16:8080 Masq 1 0 0
-> 10.233.92.17:8080 Masq 1 0 0
-> 10.233.96.12:8080 Masq 1 0 0
TCP 10.233.90.0:31191 sh
-> 10.233.90.16:8080 Masq 1 0 0
-> 10.233.92.16:8080 Masq 1 0 0
-> 10.233.92.17:8080 Masq 1 0 0
-> 10.233.96.12:8080 Masq 1 0 0

Reference

設定 Google Cloud Platform 第三方服務金鑰

這次是將現有的 Google Cloud Functions (GCF) 專案透過 GitHub Actions 自動化部屬至 Google Cloud Platform (GCP)。GitHub Actions 是第三方的服務,GCP 須建立金鑰給第三方服務使用,這篇將會記錄要如何設定 GCP 的金鑰。

環境

  • Google Cloud Platform: 需先建立專案,並開啟帳單功能,ProjectID 為 short-url-256304。

方法

建立憑證金鑰

  1. 點選「 IAM 與管理員」
  2. 點選「服務帳戶」
  3. 點選「建立服務帳戶」

GCP IAM STEP 1

  1. 輸入服務帳戶名稱
  2. 點選「建立」

GCP IAM STEP 2

  1. 點選「角色」-> 選擇要部署要應用程式「XXX開發人員」(下圖以 Gloud Functions 為例) -> 點選「建立」

GCP IAM STEP 3

  1. 點選「建立金鑰」

GCP IAM STEP 4

  1. 選擇金鑰類型「JSON」
  2. 點選「建立」,此時會下載一份金鑰檔,將這份檔案保存好
  3. 點選「完成」

GCP IAM STEP 5

  1. 建立完成畫面

GCP IAM STEP 6

賦予服務帳戶權限

這時候根據建立完還不能透過第三方部署應用程式,會出現以下錯誤訊息:

1
2
3
ERROR: (gcloud.functions.deploy) ResponseError: status=[403], code=[Forbidden], message=[Missing necessary permission iam.serviceAccounts.actAs for ***@appspot.gserviceaccount.com on project ***. 
Please grant ***@appspot.gserviceaccount.com the roles/iam.serviceAccountUser role.
You can do that by running 'gcloud projects add-iam-policy-binding *** --member=***@appspot.gserviceaccount.com --role=roles/iam.serviceAccountUser'

錯誤訊息有提示你要如何解決這個問題,所以照提示訊息輸入以下指令,需將 PROJECT_IDDEPLOY_NAME 改成你現在使用的專案:

1
2
3
4
export PROJECT_ID=short-url-256304
export DEPLOY_NAME=deploy-to-gcp

gcloud iam service-accounts add-iam-policy-binding $PROJECT_ID@appspot.gserviceaccount.com --member=serviceAccount:$DEPLOY_NAME@$PROJECT_ID.iam.gserviceaccount.com --role=roles/iam.serviceAccountUser --project=$PROJECT_ID

操作步驟:

  1. 點選右上角圖示「啟用 Cloud Shell」
  2. 在終端機上輸入賦予帳戶權限的指令

GCP IAM STEP 7

測試

使用的 Repo 是 akiicat/short-url,透過 GitHub Actions 自動部署至 Google Cloud Functions 的設定檔,注意 jobs.deploy.steps[] 裡的前兩個步驟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# .github/workflows/google-cloud-functions.yml
name: GoogleCloudFuncitons
on: [push]
jobs:
deploy:
name: GCP Authenticate
runs-on: ubuntu-latest
steps:
- name: Setup Google Cloud
uses: actions/gcloud/auth@master
env:
# https://github.com/actions/gcloud/issues/13#issuecomment-511596628
GCLOUD_AUTH: ${{ secrets.GCLOUD_AUTH }}

- name: GCP Setup Project ID
uses: actions/gcloud/cli@master
with:
args: "config set project ${{ secrets.ProjectID }}"

- name: GCP Functions List
uses: actions/gcloud/cli@master
with:
args: "functions list"

- name: Deploy to GCP
uses: actions/gcloud/cli@master
with:
args: "functions deploy link --entry-point=Link --runtime=go111 --trigger-http"

第一個步驟(Setup Google Cloud),是使用先前下載 JSON的金鑰向 Google Cloud Platform 認證權限,這裡的 GCLOUD_AUTH 需先將 JSON 檔轉成 base64 的格式,可以參考這篇 issue

1
2
3
$ base64 short-url-256304-287.json
ewogICJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCIsCiAgInByb2plY3RfaWQiOiAic2hvcnQtdXJsLTI1NjMwNCIsCiAgI...
lcGxveS10by1nY2YlNDBzaG9ydC11cmwtMjU2MzA0LmlhbS5nc2VydmljZWFjY291bnQuY29tIgp9Cg==

第二個步驟(GCP Setup Project ID),是設定 Google Cloud Platform 的 Project ID

secrets.GCLOUD_AUTHsecrets.ProjectID 的設定請參考 Virtual environments for GitHub Actions

Reference