0%

這段時間在製作一個 snippet 管理的 side project,過程中遇到了一些跨頁面共享資料的需求。專案中有一包資料稱為 snippet,內部包含多個 folder 和小項目的 snippets。這些資料不僅會在 /snippet 路徑下的元件使用,還會跨 route 被其他頁面存取。

一開始,我選擇使用 React 的 Context API,透過建立一個 SnippetsContext 來管理狀態。在應用程式的最上層放置一次 SnippetsProvider,然後在需要的任何元件內直接使用 import { useSnippets } from ‘@/contexts/SnippetsContext’ 來取得 context。然而,隨著專案規模逐步擴展,這種方法逐漸顯露出其局限性。我發現即使已經有了最上層的 Provider,但在多個 dialog 或 component 中,仍然需要重複地導入 useSnippets,不僅造成代碼重複,也使得專案結構變得臃腫複雜。

為了解決上述問題,我嘗試了另一個狀態管理工具 —— Zustand。Zustand 提供了一種更直覺、更精簡的方式來建立和存取全域狀態,不需要透過 Provider 包裹元件,大幅減少了樣板代碼的使用,也讓資料流更加清晰易懂。

在這篇文章中,我會分享如何在專案中實際導入並使用 Zustand

初步實作 Zustand

1
npm install zustand

如果想使用持久化功能,也可以安裝:

1
npm install zustand/middleware

store 資料夾

在專案的 src 資料夾下建立一個 store 資料夾,這之中包含給 folder , snippet 的 slice。之所以採用 slice 是因為原本 snippet 的處理邏輯較為複雜,包含了 folder 的儲存、內部 snippet 的儲存,以及一些專門給 dialog 使用的 UI 狀態。透過將 store 分拆成多個 slice(例如 folderSlice 與 uiSlice),每個模組都只負責處理自己相關的 state 與操作,能更好地維護代碼的結構與可讀性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
src/
├── types/
│ └── snippet.ts // 共用型別,也可以有 auth.ts, settings.ts 等
└── stores/
├── snippet/
│ ├── slices/
│ │ ├── folderSlice.ts
│ │ ├── snippetSlice.ts
│ │ └── uiSlice.ts
│ └── index.ts // snippetStore.ts
├── auth/
│ └── index.ts // 假如之後, authStore.ts (可以進一步拆分 authSlices)
└── settings/
└── index.ts // 假如之後,settingsStore.ts

在專案中的 snippet 資料夾內,會建立一個 index.ts 檔案來整合所有獨立的 slice,並導出一個完整的 Zustand store。這麼做的優點是:之後在任何元件中需要使用狀態時,只需導入整個 store,而不需要逐一引入每個 slice

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/stores/snippet/index.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { FolderSlice, createFolderSlice } from './slices/folderSlice';
import { SnippetSlice, createSnippetSlice } from './slices/snippetSlice';
import { UISlice, createUISlice } from './slices/uiSlice';

// 組合所有模組的型別
export type AppStore = FolderSlice & SnippetSlice & UISlice;

export const useSnippetStore = create<AppStore>()(
persist(
(set, get, api) => ({
...createFolderSlice(set, get, api),
...createSnippetSlice(set, get, api),
...createUISlice(set, get, api),
}),
{
name: 'my-snippets-storage',
// partialize 使我們只持久化 folders,UI 狀態不必存入 localStorage
partialize: (state) => ({ folders: state.folders }),
}
)
);
  • 使用 zustand 提供的 persist middleware,能輕鬆將特定狀態持久化到 localStorage。
  • 透過 partialize 參數,我能夠靈活地指定只有某些狀態 (例如:folders) 需要持久化,而 UI 或其他臨時狀態則保持記憶體內即可。

folderSlice.ts

在建立 folderSlice 時,透過 Zustand 的 StateCreator 定義了一個 slice,專門負責管理資料夾(folders)相關的狀態與邏輯。透過這個方式,能清楚地將資料夾的操作行為集中管理。

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// src/stores/snippet/slices/folderSlice.ts
import { StateCreator } from 'zustand';
import { Folder } from '@/types/snippets';

export interface FolderSlice {
folders: Folder[];
setFolders: (folders: Folder[]) => void;
updateFolder: (id: string, updates: Partial<Folder>) => void;
addFolder: (folder: Omit<Folder, "id">, index?: number) => Folder;
deleteFolder: (id: string) => void;
}

export const createFolderSlice: StateCreator<FolderSlice> = (set, get) => ({
folders: [
{
id: 'HplOMyf2mDqvVMdphJbt',
name: 'My Sample Snippets',
description: 'This is a sample folder',
snippets: [
{
id: '5mJw031VPo2WxNIQyeXN',
name: 'Demo - Plain text',
content: 'be a software engineer',
shortcut: '/do',
},
{
id: '6mJw031VPo2WxNIQyeYN',
name: 'Demo - Styled Text',
content:
'be a translate expert, I will give you a sentence and help me translate to english',
shortcut: '/doT',
},
],
},
{
id: 'folder-1741057188488',
name: 'Test',
description: 'test',
snippets: [
{
id: 'snippet-1741057206823',
name: 'test',
content: '<p>New snippet content Test</p>',
shortcut: '/test',
},
],
},
],
setFolders: (folders) => set({ folders }),
updateFolder: (id, updates) =>
set({
folders: get().folders.map((folder) =>
folder.id === id ? { ...folder, ...updates } : folder
),
}),
// 新增資料夾,可選擇插入位置
addFolder: (folder, index) => {
const newFolder: Folder = { ...folder, id: `folder-${Date.now()}` };
set((state) => ({
folders:
typeof index === "number"
? [
...state.folders.slice(0, index),
newFolder,
...state.folders.slice(index),
]
: [...state.folders, newFolder],
}));
return newFolder;
},
deleteFolder: (id) =>
set({
folders: get().folders.filter((folder) => folder.id !== id),
}),
});
  • 使用 set 來更新狀態,get 取得當前狀態。
  • addFolder 方法可指定新增資料夾的位置(透過可選的 index 參數);若未指定,則預設新增到列表尾端。

snippetSlice.ts

snippetSlice 負責 snippet 的新增、刪除與更新操作。由於 snippets 存放於 folders 中,因此此 slice 會依賴 FolderSlice 的狀態來進行資料處理。

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
52
53
54
// src/stores/snippet/slices/snippetSlice.ts
import { StateCreator } from 'zustand';
import { Snippet } from '@/types/snippets';
import { FolderSlice } from './folderSlice';

export interface SnippetSlice {
addSnippetToFolder: (folderId: string, snippet: Omit<Snippet, 'id'>) => Snippet;
deleteSnippetFromFolder: (folderId: string, snippetId: string) => void;
updateSnippet: (snippetId: string, updatedSnippet: Partial<Snippet>) => void;
}

// 這裡依賴 FolderSlice,因為 snippets 都儲存在 folders 內
// 修改後,addSnippetToFolder 回傳新 snippet
export const createSnippetSlice: StateCreator<
FolderSlice & SnippetSlice,
[],
[],
SnippetSlice
> = (set, get) => ({
// 新增一個 snippet 到指定的資料夾,並回傳新增的 snippet(包含自動生成的 id)
addSnippetToFolder: (folderId, snippet) => {
const newSnippet: Snippet = { ...snippet, id: `snippet-${Date.now()}` };
set({
folders: get().folders.map((folder) =>
folder.id === folderId
? { ...folder, snippets: [...folder.snippets, newSnippet] }
: folder
),
});
return newSnippet; // 回傳新增後完整的 snippet 物件
},
deleteSnippetFromFolder: (folderId, snippetId) =>
set({
folders: get().folders.map((folder) =>
folder.id === folderId
? {
...folder,
snippets: folder.snippets.filter(
(snippet) => snippet.id !== snippetId
),
}
: folder
),
}),
updateSnippet: (snippetId, updatedSnippet) =>
set({
folders: get().folders.map((folder) => ({
...folder,
snippets: folder.snippets.map((snippet) =>
snippet.id === snippetId ? { ...snippet, ...updatedSnippet } : snippet
),
})),
}),
});
  • 新增 snippet(addSnippetToFolder)

    提供一個資料夾 id 和 snippet 的內容(不含 id)。
    自動產生 snippet 的 id(透過 Date.now())。
    將新的 snippet 放到指定的資料夾內,並回傳完整的新 snippet,方便後續操作使用。

  • 刪除 snippet(deleteSnippetFromFolder)

    根據指定的資料夾 id 和 snippet id 移除 snippet。
    使用 filter 方法,確保其他 snippet 不受影響。

  • 更新 snippet(updateSnippet)

    -提供 snippet id 和欲更新的部分 snippet 屬性。
    -使用 map 方法,只更新符合指定 id 的 snippet,其他 snippet 維持不變。

uiSlice.ts

uiSlice 專門用來管理專案內與 UI 顯示相關的狀態,例如 dialog(對話框)的開啟或關閉狀態,以及當前匹配(matched)的 snippet 資訊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/stores/snippet/slices/uiSlice.ts
import { StateCreator } from 'zustand';
import { MatchedSnippet } from '@/types/snippets';

export interface UISlice {
isDialogOpen: boolean;
matchedSnippet: MatchedSnippet;
setIsDialogOpen: (open: boolean) => void;
setMatchedSnippet: (snippet: MatchedSnippet) => void;
}

export const createUISlice: StateCreator<UISlice> = (set) => ({
isDialogOpen: false,
matchedSnippet: {
content: '',
targetElement: null,
insert: false,
shortcut: '',
},
setIsDialogOpen: (open: boolean) => set({ isDialogOpen: open }),
setMatchedSnippet: (snippet: MatchedSnippet) => set({ matchedSnippet: snippet }),
});
  • isDialogOpen
    負責追蹤 dialog 是否正在顯示,便於控制 dialog 元件的顯示與隱藏。
  • matchedSnippet
    用於暫時儲存用戶目前匹配到的 snippet 資訊,例如 snippet 的內容、目標元素、快捷鍵資訊等等,讓 UI 能即時反應用戶操作。

在頁面中使用 Zustand 管理的狀態

想在頁面或元件內使用剛才建立的 Zustand 狀態時,只需從我們整合好的 useSnippetStore 引入所需的狀態或方法即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useSnippetStore } from '@/stores/snippet';
import { useEffect } from 'react';
import { Snippet } from '@/types/snippets';

function MyComponent() {
// 從整合後的 store 直接取出需要的方法
const { addFolder } = useSnippetStore();

useEffect(() => {
// 舉例:在元件載入時新增一個 folder
const newFolder = addFolder({
name: 'New Folder',
description: 'Example folder created on load',
snippets: [],
});

console.log('新增了資料夾:', newFolder);
}, [addFolder]);

return <div>你的元件內容</div>;
}

  • 不再需要額外引入任何 Context Provider。
  • 在任何需要存取全局狀態的地方,只需簡單導入 useSnippetStore,然後直接調用對應的方法或取得狀態即可。

結論

藉由導入 Zustand 並將狀態拆分成多個獨立的 Slice(folderSlice、snippetSlice、uiSlice),使我能夠:

  • 清晰地分離狀態責任:每個 Slice 專注於處理特定領域的邏輯,如資料夾管理、snippet 操作,以及 UI 狀態。
  • 靈活管理持久化策略:透過 Zustand 提供的 middleware(如 persist 與 partialize),只將必要的資料(例如資料夾)持久化至 localStorage,而 UI 等臨時狀態則保留在記憶體中。
  • 有效降低耦合度與樣板代碼:每個元件不再需要額外引入 Provider,簡化了開發流程,並且降低未來維護成本。

有新東西就是要來快速嘗試一下~~ 注意 cursor 要是付費版才能使用

至 GitHub clone 專案 Cursor Talk to Figma MCP

然後遵循 README:Get Started 開始

它需要安裝 bun 套件管理工具

1
curl -fsSL https://bun.sh/install | bash

需要在你的專案根目錄執行:

1
2
cd /path/to/your/cursor-talk-to-figma-mcp
bun setup
1
2
3
4
5
# 啟動 WebSocket server(必須持續執行)
bun socket

# 啟動 MCP server(另開一個終端)
bunx cursor-talk-to-figma-mcp
1
2
3
4
5
6
7
8
9
10
// Add the server to your Cursor MCP configuration in ~/.cursor/mcp.json:

{
"mcpServers": {
"TalkToFigma": {
"command": "bunx",
"args": ["cursor-talk-to-figma-mcp"]
}
}
}

以下為圖示:

image.png

Read more »

在 Vue 中使用動態事件名稱的功能自 Vue 2.6.0 版本開始支持。可以使用 v-on 或 @ 來動態綁定事件名稱

簡單舉例:
根據裝置類型切換事件(桌機 vs 手機)

1
2
3
4
5
6
<script setup>
const event = ref(navigator.maxTouchPoints ? 'touchstart' : 'click')
const handle = () => console.log('Triggered on', event.value)
</script>

<button @[event]="handle">按我</button>

2.「檔案卡片」元件預覽或跳轉:

點擊 (click):選擇/下載檔案
滑鼠移入 (mouseover):預覽檔案摘要

1
2
3
4
5
6
7
8
9
10
<template>
<div class="file-card">
<button v-on="{ click: handleDownload, mouseover: handlePreview }">
{{ fileName }}
</button>
<div v-if="showPreview" class="preview-popup">
{{ previewText }}
</div>
</div>
</template>
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
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'

const props = defineProps<{ fileName: string; fileUrl: string }>()
const showPreview = ref(false)
const previewText = ref('Loading preview…')

// 點擊 → 觸發下載
const handleDownload = () => {
window.open(props.fileUrl, '_blank')
}

// 滑鼠移入 → 取得並顯示檔案摘要
const handlePreview = async () => {
showPreview.value = true
// 模擬 API 請求
await new Promise(res => setTimeout(res, 300))
previewText.value = `Preview of ${props.fileName}`
}

// 滑鼠移出後隱藏預覽
const hidePreview = () => {
showPreview.value = false
}

// 監聽滑鼠離開整個卡片
const cardRef = ref<HTMLElement | null>(null)
cardRef.value?.addEventListener('mouseleave', hidePreview)
</script>

<style scoped>
.file-card { position: relative; display: inline-block; }
.preview-popup {
position: absolute;
top: 100%;
left: 0;
padding: 0.5rem;
border: 1px solid #ccc;
background: white;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
</style>

Demo

注意:在「同一欄位會因不同情境而切換觸發事件」時,才建議用動態綁定。若只是單純切換事件名稱,建議直接在方法內判斷即可。

也可參考 Michael Hoffmann 這裡的另一實作方式:
https://stackblitz.com/edit/vue-tip-dynamic-event-names?file=src%2FApp.vue

在開發 React 或 Vue 應用時,動態組合 Tailwind CSS class 是常見的需求。但當 class 來源多樣(例如 props 傳入、條件渲染等),你可能會遇到 class 重複、衝突或難以維護的問題,這時候,twMerge 就能幫助你解決這些困難。

什麼是 twMerge

twMerge 是一個專為 Tailwind CSS 設計的 class 合併工

  • 衝突解決:當你從不同來源(例如預設樣式和 props 傳入的 class)合併 class 時,可能會出現相同屬性的重複定義,twMerge 會自動解決這些衝突,保證最終只保留正確的 class。
  • 動態組合:在實際應用中,很多時候 class 字符串是根據條件組合出來的,直接拼接可能會導致意料之外的覆蓋。twMerge 能夠讓你的 class 合併更有條理,避免混淆。

安裝方式:

1
2
3
4

npm install tailwind-merge
# 或
yarn add tailwind-merge

基本使用範例:

1
2
3
4
5
6

import { twMerge } from 'tailwind-merge';

const classes = twMerge("p-4", "p-2");
console.log(classes); // 輸出: "p-2"(自動移除衝突的 p-4)

Read more »

  • S 指的是 Single responsibility principle(SRP) 單一職責原則
  • O 指的是 Open/close principle(OCP) 開放/封閉原則
  • L 指的是 Liskov substitution principle(LSP) Liskov 替換原則
  • I 指的是 Interface Segregation Principle(ISP) 介面隔離原則
  • D 指的是 Dependency Inversion Principle(DIP) 依賴反轉原則

Single responsibility principle(SRP) 單一職責原則

一個 class 別應該只有一個職責,並且只應該因為一個理由而改變。

C# 程式碼範例:

以違反 SRP 的範例:
在這個範例中,User 類別同時負責使用者資料的管理、資料庫儲存、發送歡迎信和使用者驗證等多個職責。這違反了 SRP。

1
2
3
4
5
6
7
8
9
10
11

public class User
{
public string Username { get; set; }
public string Password { get; set; }
public string Email { get; set; }

public void SaveUserToDatabase() { /* 資料庫儲存邏輯 */ }
public void SendWelcomeEmail() { /* 發送歡迎信邏輯 */ }
public bool ValidateUser() { /* 使用者驗證邏輯 */ return true; }
}

符合 SRP 的範例:

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

public class User
{
public string Username { get; set; }
public string Password { get; set; }
public string Email { get; set; }
}

public class UserRepository
{
public void SaveUser(User user) { /* 資料庫儲存邏輯 */ }
}

public class EmailService
{
public void SendWelcomeEmail(User user) { /* 發送歡迎信邏輯 */ }
}

public class UserValidator
{
public bool ValidateUser(User user) { /* 使用者驗證邏輯 */ return true; }
}

在這個範例中,我們將不同的職責分離到不同的類別中:

  • User 類別只負責使用者資料的管理。
  • UserRepository 類別負責資料庫儲存。
  • EmailService 類別負責發送歡迎信。
  • UserValidator 類別負責使用者驗證。
    這樣做使得每個類別都只有一個職責,並且只因為一個理由而改變,符合了 SRP。

從前端的解度來理解

在前端開發中,我們經常使用元件 (Component) 的概念來構建使用者介面。一個元件通常負責渲染一部分的 UI,並處理與該部分 UI 相關的互動。這與 SRP 的概念非常相似。一個好的前端元件應該只負責單一的職責,例如:

顯示資料: 只負責接收資料並將其渲染到 UI 上。
處理使用者輸入: 只負責處理使用者在介面上的輸入事件,例如按鈕點擊、表單提交等。
發送 API 請求: 只負責向後端發送 API 請求並處理回應。

Read more »

除了基礎運算子(如 Where、Select 和 OrderBy),LINQ 還提供了許多進階運算子,用於解決更複雜的查詢需求。以下是一些使用頻率較高的進階運算子,以及它們的功能與應用場景。

Projection Operators 投影

Select

範例:投影字串清單中每個字串的第一個字母。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
List<string> words = ["an", "apple", "a", "day"];

var query = words.Select(word => word.Substring(0, 1));

foreach (string s in query)
{
Console.WriteLine(s);
}

/* This code produces the following output:

a
a
a
d
*/

SelectMany

SelectMany 將一個集合中的子集合展平(Flatten),生成單一層級的結果序列。它非常適合處理嵌套集合。

適用場景:需要從多個子集合中提取元素,並將它們組合為單一集合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
List<string> phrases = ["an apple a day", "the quick brown fox"];

var query = from phrase in phrases
from word in phrase.Split(' ')
select word;

foreach (string s in query)
{
Console.WriteLine(s);
}

/* This code produces the following output:

an
apple
a
day
the
quick
brown
fox
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
List<string> phrases = ["an apple a day", "the quick brown fox"];

var query = phrases.SelectMany(phrases => phrases.Split(' '));

foreach (string s in query)
{
Console.WriteLine(s);
}

/* This code produces the following output:

an
apple
a
day
the
quick
brown
fox
*/

在提供一個,處理多對多關係數據(如學生和他們的選修課程)。
範例:攤平學生的課程清單

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

var students = new[]
{
new { Name = "Alice", Courses = new[] { "Math", "Physics" } },
new { Name = "Bob", Courses = new[] { "Biology", "Chemistry" } }
};

// 展平成單一課程清單
var allCourses = students.SelectMany(s => s.Courses);

Console.WriteLine("所有課程:");
foreach (var course in allCourses)
{
Console.WriteLine(course);
}

輸出結果

1
2
3
4
5
6

所有課程:
Math
Physics
Biology
Chemistry
Read more »

C# 的 LINQ (Language Integrated Query,語言整合查詢) 是一個非常強大的功能,它讓你可以使用類似 SQL 的語法來查詢各種不同的資料來源。這意味著你可以用統一的方式來處理資料,無論資料是來自陣列、列表、資料庫、XML 或其他任何支援 LINQ 的來源。

LINQ 的兩種語法:

LINQ 有兩種主要的語法形式:

  • 查詢語法 (Query Syntax): 看起來很像 SQL 語法,使用 from、where、select 等關鍵字。這種語法比較易讀,尤其是在處理複雜的查詢時。
  • 方法語法 (Method Syntax): 使用擴充方法 (Extension Methods) 來表示查詢運算子。這種語法更簡潔,也更靈活。簡單的查詢或需要鏈式操作時,通常使用方法語法更為方便。
Read more »

class 是物件導向程式設計 (OOP) 語言的核心功能。它通過邏輯性地組織方法和屬性,來表示並封裝特定的問題概念。

建立 class 與 instance

假設要建立一 person 的 class

1
2
3
4
5
6
7
8
9
10
11
12
13
14

public class Person
{

}

public class Program
{
public static void Main()
{
Person person = new Person();
}
}

Read more »

新的一年有機會在公司內部接觸一些些後端語言,公司的後端是 ASP.Net,所以開啟了 C# 學習之路
最最初階的學習資源,目前是來自:
dotnetcademy
適用於初學者的 C# 基礎知識

宣告變數,需指定其類型並提供名稱

C# 是 強型別語言 (Strongly Typed Language),這表示每個變數 (Variable) 或常數 (Constant) 在使用之前都必須先明確定義其型別 (Type)。

1
2
3
4
string greeting;
int a, b, c;
List<double> xs;

Read more »

Write a class that allows getting and setting key-value pairs, however a time until expiration is associated with each key.

The class has three public methods:

set(key, value, duration): accepts an integer key, an integer value, and a duration in milliseconds. Once the duration has elapsed, the key should be inaccessible. The method should return true if the same un-expired key already exists and false otherwise. Both the value and duration should be overwritten if the key already exists.

get(key): if an un-expired key exists, it should return the associated value. Otherwise it should return -1.

count(): returns the count of un-expired keys.

中文說明

set(key, value, duration): 接受一個整數的 key、一個整數值 value,以及一個毫秒為單位的時間 duration。當持續時間結束後,這個鍵值對將不能被存取。如果相同且未過期的鍵存在,則回傳 true,若不存在則回傳 false。如果這個鍵已經存在,其值與持續時間都應該被新傳入的 value 與 duration 覆寫。
get(key):如果存在未過期的鍵,回傳其對應的值。若沒有相對應的鍵,則回傳 -1。
count():回傳現存未過期的鍵的總數量。

Read more »