比較 Svelte 渲染元件的三個方法:mount vs render vs hydrate

Svelte Version: 5.25.7

Outline

  • render():在 Server 端上,將從 Component 產生 HTML。
  • mount():在 Client 端上,從空的 DOM 節點建立 Component 的 DOM 附加到 HTML。
  • hydrate():混合 render()mount()。功能上類似 mount(),但不是從空的 DOM 節點開始,而是從 Server 端 render() 過的資料開始建立。

Svelte Render VS Render VS Hydrate

使用時機

如果需要在 server 端渲染 e.g. SEO 的需求,可以使用 render(),Server 端會將完整的 HTML 輸出至 Client 端。而 mount() 完全相反,僅在 Client 端時建立 DOM 節點。而 hydrate() 同時兼顧了 Server 端 SEO 的需求與 Client 端與使用者互動的需求,

render:Server 端渲染

當您在 Server 端上使用 render() 功能時,Component 將被編譯為 HTML 字串,可以將其傳送到 Client 端:

1
2
3
4
5
6
7
8
9
10
11
// Server-side code (e.g., in a Node server)
import { render } from 'svelte/server';
import App from './App.svelte';

const result = render(App, {
props: { some: 'property' }
});

// Then send `result` as the response to the client.
result.body; // HTML <body> tag
result.head; // HTML <head> tag

App 為 Svelte 的 Componenet,props 則是傳入 App 的屬性參數。

mount:Client 端渲染

如果你有一個空 DOM 節點或是即沒有預先渲染的 HTML e.g. <div id="app"></div> 並且希望 Svelte 在 Client 端上從頭建立 DOM,可以使用 mount()

1
2
3
4
5
6
7
import { mount } from 'svelte';
import App from './App.svelte';

const app = mount(App, {
target: document.querySelector('#app'),
props: { some: 'property' }
});

hydrate:升級伺服器渲染的 HTML

假設以下程式碼已在 Server 端產生的 HTML 並傳送到 Client 端:

1
<div id="app">something</div>

我們可以在 Client 端上使用 hydrate() 來讓 Server 端渲染的物件具有互動性 (interactive)。

1
2
3
4
5
6
7
import { hydrate } from 'svelte';
import App from './App.svelte';

const app = hydrate(App, {
target: document.querySelector('#app'),
props: { some: 'property' }
});

可以想像成 Server 端已經渲染好 HTML,用 hydrate() 附加額外邏輯和事件處理來「提升」此靜態 HTML 的功能:

Reference

React vs Svelte 開發體驗:子元件暫時更新父層資料

當程式規模慢慢變大時,為了管理與維護會將大元件拆分成許多小元件,在父子元件之間傳遞資料是開發時很常見的。但在傳遞資料上,React 跟 Svelte 沒什麼區別,同樣都是將資料傳入元件的 attribute 中,子元件透過 props 來獲得父層傳入的資料。所以我們這章舉一個比較複雜一點的例子:讓子元件可以暫時更新資料,但如果父元件的資料更新時,子元件也要更新為父元件元件的值。我們將透過範例比較兩者在開發體驗上的差異。

開發體驗

開發者在使用工具、框架、語言、API、系統或流程時,整體的感受、效率、流暢度與滿意度。

使用情境

當你在開發表單、步驟流程或是可編輯區塊時,子元件常會需要「暫時修改」父元件提供的資料。例如使用者輸入值,但尚未儲存前,這些變更應該是本地的。這時候就需要讓子元件在不直接修改父層狀態的前提下,保有一定程度的控制權。

React:需要手動管理狀態同步

React 採用單向資料流設計,而且我們無法直接修改 props。在子元件想要暫時更新資料時,必須透過 useState 加上 useEffect 來同步 props 的變化 (demo)。

ParentComponent.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { useState } from 'react';
import ChildComponent from './ChildComponent';

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

return (
<div>
<button onClick={() => setCount(count + 1)}>
clicks (parent): {count}
</button>
<ChildComponent count={count} />
</div>
);
}

export default ParentComponent;

ChildComponent.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { useState, useEffect } from 'react';

function ChildComponent({ count }) {
const [localCount, setLocalCount] = useState(count); // create a local count for temporary use

useEffect(() => {
setLocalCount(count);
}, [count]);

const increment = () => {
setLocalCount(localCount + 1);
};

return (
<button onClick={increment}>
clicks (child): {localCount}
</button>
);
}

export default ChildComponent;

在這個範例中:

  • 子元件透過 useState 創建一個本地的 localCount,用來「暫時更新」畫面上的計數值。
  • 當父元件的 count 改變時,useEffect 會同步更新 localCount
  • 子元件的操作不會影響父元件的實際資料。

Svelte:直覺操作、語法簡潔

Svelte 允許我們在子元件中直接修改 props,而不需要額外建立本地狀態。這種語法大幅簡化了開發流程,對開發者非常友善 (demo)。

Parent.svelte

1
2
3
4
5
6
7
8
9
10
11
<script>
import Child from './Child.svelte';

let count = 0;
</script>

<button onclick={() => (count += 1)}>
clicks (parent): {count}
</button>

<Child {count} />

Child.svelte

1
2
3
4
5
6
7
<script>
let { count } = $props();
</script>

<button onclick={() => (count += 1)}>
clicks (child): {count}
</button>
  • 無需 useEffect 或本地狀態。
  • 可直接操作傳入的 count,操作簡單明瞭。
  • 預設變更僅限於子元件本地,不會同步到父層(除非你明確綁定 bind: 或使用事件)。

以上 React 與 Svelte 兩個例子功能相同,但 Svelte 的語法設計,讓開發者可以更快速專注在邏輯本身,而非額外的狀態管理機制。

延伸閱讀

React vs Svelte 開發體驗:雙向資料綁定

資料綁定 (Data Binding) 是連結應用程式資料 (Application) 與使用者介面 (UI) 的方式或機制。React 是使用單向資料綁定 (one-way data flow),需手動呼叫 setState 來更新狀態;而 Svelte 使用 bind: 指令來達成雙向資料綁定。我們將透過範例來了解兩者在開發體驗上的差異。

開發體驗

開發者在使用工具、框架、語言、API、系統或流程時,整體的感受、效率、流暢度與滿意度。

使用情境

當你在開發表單、表單輸入與使用者互動或即時資料顯示,需要反向更新狀態變數,並即時顯示給使用者 (e.g. 動態價格計算、表單即時驗證、即時預覽)。

React:單向資料綁定

React 採用單向資料流 (one-way data flow),透過手動呼叫 setstate 來更新狀態。

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

function InputComponent() {
const [text, setText] = useState('');

return (
<>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<p>{text}</p>
</>
);
}
  • useState 宣告狀態變數 text,初始值為空字串。
  • value={text} 綁定輸入框顯示值。
  • onChange 在輸入變動時更新 text

Svelte:直覺式雙向綁定

Svelte 提供了 bind: 指令,簡化雙向資料綁定,讓變數與元件保持同步。

1
2
3
4
5
6
<script>
let text = '';
</script>

<input type="text" bind:value={text} />
<p>{text}</p>
  • 使用 let 宣告響應式 (reactivity) 變數 text
  • bind:value={text} 自動雙向綁定輸入值與變數。
  • 使用者輸入會更新 text,變數更新也會同步回輸入框。

哲學

React 與 Svelte 在資料綁定上的差異,反映了各自的框架哲學:

  • React 注重控制與可預測性,適合處理複雜應用邏輯與資料流。
  • Svelte 注重簡潔與開發效率,讓使用者開發時更貼近原生思維。

延伸閱讀

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

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

開發體驗

開發者在使用工具、框架、語言、API、系統或流程時,整體的感受、效率、流暢度與滿意度。

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