什麼是結構共享(Structural Sharing)?為什麼淺拷貝搭配結構共享既安全又有效率?
如果你寫過 React、Redux 或用過 Immer、React Query,你其實早就天天在使用「結構共享」了,只是可能沒有意識到。這篇文章會用最淺白的方式,帶你搞懂這個概念。
一、先從「不可變資料(Immutable Data)」說起
在 React 的世界裡,有一條鐵律:不要直接修改 state,而是要產生一個新的物件。
// ❌ 錯誤:直接修改
state.user.name = '小明';
// ✅ 正確:產生新物件
setState({ ...state, user: { ...state.user, name: '小明' } });
為什麼要這麼麻煩?因為 React 判斷「資料有沒有變」的方式非常簡單粗暴——比較記憶體位址(reference),也就是 oldState === newState。如果你直接改舊物件,位址沒變,React 會以為「什麼都沒發生」,畫面就不會更新。
但這裡出現一個疑問:
「每次都要複製一份新的資料,如果資料很大,不會很浪費效能和記憶體嗎?」
答案是:不會,因為我們用的是淺拷貝 + 結構共享。
二、什麼是結構共享(Structural Sharing)?
用搬家來比喻
想像你的 state 是一棟三層樓的房子:
房子(state)
├── 一樓:user資料
├── 二樓:todos清單
└── 三樓:settings設定
現在你只想改「一樓的一張沙發」。你會怎麼做?
- 深拷貝(Deep Copy)的做法:把整棟房子連同每一層的每一件家具,全部重新打造一遍,再換掉那張沙發。→ 超級浪費!
- 結構共享的做法:蓋一個新的「房子外殼」,一樓重新裝潢(換沙發),但二樓和三樓直接沿用原本的,連動都不用動。
用程式碼表達就是:
const oldState = {
user: { name: '小華', age: 25 },
todos: [ /* 一萬筆待辦事項 */ ],
settings: { theme: 'dark' },
};
// 只改 user.name
const newState = {
...oldState, // 蓋新的外殼
user: { ...oldState.user, name: '小明' }, // 一樓重新裝潢
};
// 見證奇蹟的時刻:
console.log(newState === oldState); // false → 外殼是新的
console.log(newState.user === oldState.user); // false → user 是新的
console.log(newState.todos === oldState.todos); // true → 共享!
console.log(newState.settings === oldState.settings); // true → 共享!
那一萬筆的 todos 完全沒有被複製,新舊 state 指向的是同一份 todos。這就是結構共享:
只複製「有改動的那條路徑」,沒改動的部分,新舊資料直接共用同一份。
圖解
oldState newState
│ │
├── user(舊) ├── user(新)← 只有這條路徑是新的
│ │
├── todos ────┐ ┌──────── todos
│ ▼ ▼
│ 【同一份資料】← 共享,零複製成本
│
├── settings ─┐ ┌──────── settings
▼ ▼
【同一份資料】← 共享
三、什麼是淺拷貝(Shallow Copy)?
淺拷貝就是「只複製最外面那一層,裡面的東西照舊引用」。
const original = {
name: '小華',
hobbies: ['爬山', '游泳'],
};
const copy = { ...original }; // 展開運算子就是淺拷貝
console.log(copy === original); // false → 外層是新物件
console.log(copy.hobbies === original.hobbies); // true → 內層還是同一份
常見的淺拷貝方式:
| 寫法 | 適用對象 |
|---|---|
{ ...obj } | 物件 |
[ ...arr ] | 陣列 |
Object.assign({}, obj) | 物件 |
arr.slice() | 陣列 |
你有沒有發現?結構共享,其實就是「一層一層地做淺拷貝」做出來的。每往下改一層,就對那一層淺拷貝一次;沒改到的分支,自然就被共享了。
四、為什麼「淺拷貝 + 結構共享」是安全的?
你可能會擔心:「新舊 state 共用同一份 todos,不會互相污染嗎?」
關鍵在於一個約定:不可變性(Immutability)——我們永遠不直接修改既有的物件。
只要遵守這條規則:
- 舊 state 裡的
todos永遠不會被改動(要改就產生新的)。 - 所以新 state 引用它,是 100% 安全的——它是「唯讀」的,不會有人偷改。
- 任何人拿到舊 state,看到的永遠是當初的樣子(這也讓「時光旅行除錯」、「undo/redo」變得超簡單)。
換句話說:
共享之所以安全,是因為被共享的東西永遠不會被修改。 危險的從來不是共享本身,而是「共享之後還去改它」。
反過來說,如果你沒有遵守不可變性(例如 state.todos.push(newItem)),那共享就會變成災難——新舊 state 會一起被改壞,而且 React 還偵測不到變化。
五、為什麼是有效率的?
1. 寫入快:只複製一條路徑
假設 state 是一棵有 10,000 個節點的樹,你只改其中一個葉子:
- 深拷貝:複製 10,000 個節點 → O(n)
- 結構共享:只複製從根到那個葉子「沿路經過」的節點,可能只有 3~4 個 → 幾乎 O(路徑深度)
2. 比較快:一行 === 就搞定
這是結構共享最大的殺手級應用。React 的 memo、useMemo、Redux 的 useSelector 都靠它:
// 想知道 todos 有沒有變?
if (newState.todos === oldState.todos) {
// 位址相同 → 保證沒變 → 這個元件不用重新 render!
}
不需要一筆一筆比對一萬筆待辦事項的內容(深度比較很貴),只要比較一次記憶體位址(奈秒等級)就能得到答案。這就是為什麼 React 元件可以精準地「只重繪有變動的部分」。
3. 省記憶體
沒被改到的資料只存在一份,一百個歷史版本的 state 可能 95% 的內容都是共用的。Redux DevTools 能記錄幾百步的 state 歷史還不爆記憶體,就是這個原因。
六、實際範例:更新巢狀資料
假設有這樣的 state,我們要把 id: 2 的待辦事項打勾:
const state = {
filter: 'all',
todos: [
{ id: 1, text: '買菜', done: false },
{ id: 2, text: '寫程式', done: false },
{ id: 3, text: '運動', done: false },
],
};
// 手動實現結構共享
const newState = {
...state, // 外層淺拷貝(filter 被共享)
todos: state.todos.map(todo => // 陣列產生新的
todo.id === 2
? { ...todo, done: true } // 只有 id:2 產生新物件
: todo // 其他 todo 直接沿用(共享!)
),
};
console.log(newState.todos[0] === state.todos[0]); // true → 共享
console.log(newState.todos[1] === state.todos[1]); // false → 新的
console.log(newState.todos[2] === state.todos[2]); // true → 共享
如果畫面上每個 todo 都是一個用 React.memo 包起來的元件,那麼只有 id:2 的那個元件會重新 render,其他兩個因為 props 位址沒變,直接跳過。效能優化就這樣自然發生了。
七、覺得手寫展開運算子很累?交給工具
巢狀一深,手寫 ... 會變得很痛苦。實務上常用這些工具,它們底層都是在幫你做結構共享:
- Immer(Redux Toolkit 內建):讓你用「直接修改」的語法,底層自動產生結構共享的新物件。
import { produce } from 'immer';
const newState = produce(state, draft => {
draft.todos[1].done = true; // 看起來像直接改,其實很安全
});
- React Query / TanStack Query:重新抓取資料後,會自動比對新舊回應,內容沒變的部分沿用舊引用,避免元件無謂地重新 render。
- Immutable.js:用特殊資料結構(如 HAMT)把結構共享做到極致,不過學習成本較高,現在較少新專案採用。
八、重點整理
| 概念 | 一句話說明 |
|---|---|
| 淺拷貝 | 只複製最外層,內層沿用原本的引用 |
| 結構共享 | 只重建「有改動的路徑」,其餘分支新舊共用 |
| 為什麼安全 | 因為搭配不可變性——被共享的資料永遠不會被修改 |
| 為什麼高效 | 寫入只複製一條路徑、變更偵測只需 ===、記憶體大量共用 |
最後記住這個心法:
深拷貝是「全部重做」,結構共享是「只換有動到的,其他借來用」——而借來用之所以不會出事,是因為大家說好了誰都不准改。