組件化(一):重新思考“組件”:狀態、視圖和邏輯的“最佳”分離實踐
引子:組件的“內憂”與“外患”
至此,我們的前端內功修煉之旅已經碩果累累。我們掌握了組件化的架構思想,擁有了高效的渲染引擎,還探索了從集中式到原子化的兩種狀態管理模式。我們似乎已經擁有了構建任何復雜應用所需的全套“武器”。
但魔鬼往往藏在細節中。今天,我們要將視線從宏觀的架構和狀態管理,拉回到我們日常工作中接觸最頻繁的單元——**組件(Component)**本身。
我們每天都在寫組件,但我們是否真正思考過:一個“好”的組件,應該是什么樣的?
想象一個常見的UserProfile
組件,它需要:
- 根據
userId
從API獲取用戶數據。 - 在獲取數據時,顯示一個加載中的
Spinner
。 - 數據獲取成功后,顯示用戶的頭像、姓名、簡介。
- 如果獲取失敗,顯示一條錯誤信息。
- 用戶點擊“關注”按鈕時,調用另一個API,并更新按鈕狀態為“已關注”。
我們可以把所有這些邏輯都寫在一個巨大的組件文件里。一開始,這似乎很方便。但隨著時間的推移,問題暴露了:
- 復用性極差:如果另一個頁面需要一個樣式不同、但數據源相同的用戶卡片,我們幾乎無法復用這個組件的任何部分,只能復制粘貼代碼。
- 測試困難:要測試這個組件,你需要模擬
fetch
API、處理各種異步情況,還要斷言最終渲染出的DOM結構。測試用例會變得異常復雜和脆弱。 - 職責不清:這個組件既關心“數據從哪來”(業務邏輯),又關心“數據長啥樣”(UI渲染)。當需求變更時,比如只是想改個樣式,你卻可能要在一大堆數據處理邏輯中小心翼翼地穿行,反之亦然。這種混合的職責,讓維護成為一場噩夢。
這就是一個組件的“內憂”——內部邏輯的混亂與耦合。而“外患”,則是它與其他組件、與數據源之間糾纏不清的關系。
為了解決這個問題,Dan Abramov(Redux的作者之一)在2015年提出了一種影響深遠的設計模式,它建議我們將組件清晰地一分為二:容器組件(Container Components)和展示組件(Presentational Components)。
今天,我們將不談具體框架語法,只用最純粹的JavaScript,來重新思考一個組件的“最佳”分離實踐。
第一幕:兩種“人格” - 容器與展示
這個模式的核心,就是將一個復雜的“智能”組件,拆分成兩種不同“人格”的組件,讓它們各司其職。
展示組件 (Presentational Components)
你可以把它想象成一個“UI木偶”或者一個“啞巴組件”(Dumb Component)。它的特點是:
- 只關心“如何展示”(How things look):它的全部職責就是根據接收到的
props
來渲染UI。 - 不擁有自身的狀態:它通常是無狀態的(Stateless),除非是管理一些純UI相關的、與業務無關的狀態(比如一個動畫的開關)。
- 不直接依賴數據源:它不知道Redux、不知道Atom、也不知道API。它所需的所有數據,都必須由父組件通過
props
明確地傳遞給它。 - 通過回調函數與外界通信:當需要觸發某個業務操作時(如點擊按鈕),它不直接執行邏輯,而是調用一個從
props
中接收的回調函數(如props.onFollowClick
)。 - 高度可復用:由于它與業務邏輯完全解耦,你可以輕松地在任何地方復用它,只要給它傳入符合預期的
props
。它就像一個“皮膚”,可以套在不同的“靈魂”上。
容器組件 (Container Components)
這則是那個“聰明的操偶師”(Smart Component)。它的特點是:
- 只關心“如何工作”(How things work):它的主要職責是管理狀態和邏輯。
- 擁有和管理狀態:它可以是Class組件中的
state
,也可以是連接到Redux Store或原子化Store的邏輯。 - 與數據源通信:它負責調用API、
dispatch
action、read/write
atom。 - 不包含復雜的UI結構:它通常不包含自己的HTML標簽(除了最外層的包裹
div
)。它的render
方法里,主要是渲染一個或多個展示組件,并將狀態和回調函數作為props
傳遞給它們。 - 復用性較低:它通常是為特定業務場景定制的,與應用的特定部分緊密相關。
清晰的職責劃分
特性 | 展示組件 (Presentational) | 容器組件 (Container) |
---|---|---|
主要目的 | UI渲染 (How things look) | 業務邏輯 (How things work) |
數據來源 | 從props 接收 | 管理自身狀態,或從Store/API獲取 |
狀態感知 | 無(或只有純UI狀態) | 有 |
| 數據修改 | 調用props
中的回調函數 | 執行業務邏輯,調用API,dispatch
action |
| 依賴 | 無(除了UI庫) | 依賴狀態管理庫、API服務等 |
| 可復用性 | 高 | 低 |
這種分離,就像是把一個人的“靈魂”(邏輯)和“肉體”(外表)分開。我們可以給同一個“靈魂”換上不同的“肉體”(比如Web版的UI和移動版的UI),也可以讓同一個“肉體”被不同的“靈魂”所驅使(比如一個通用的Button
組件,可以用在登錄、注冊、購買等不同場景)。
第二幕:用純JS模擬“組件分離”
現在,讓我們回到最初那個UserProfile
組件的例子,用純粹的、不依賴任何UI框架的JavaScript類和函數來實踐這種分離模式。
我們將使用上一章的createElement
來描述UI,用renderToString
來“看到”結果。
步驟一:設計“啞巴”的UserProfileDisplay
組件
首先,我們來創建展示組件。它是一個純函數,接收props
,返回一個描述UI的VNode。
presentational/UserProfileDisplay.js
// CSDN @ 你的用戶名
// 系列: 前端內功修煉:從零構建一個“看不見”的應用
//
// 文件: /src/v7/presentational/UserProfileDisplay.js
// 描述: 一個純粹的展示組件,負責渲染用戶資料。const { createElement } = require('../../v3/createElement');/*** 一個“啞巴”組件,它只知道如何根據props渲染UI。* @param {object} props* @param {boolean} props.isLoading - 是否正在加載* @param {object | null} props.error - 錯誤對象* @param {object | null} props.user - 用戶數據 { avatar, name, bio }* @param {boolean} props.isFollowing - 是否已關注* @param {Function} props.onFollow - 點擊關注按鈕的回調* @returns {object} VNode*/
function UserProfileDisplay({ isLoading, error, user, isFollowing, onFollow }) {if (isLoading) {return createElement('div', { class: 'profile-card loading' }, 'Loading profile...');}if (error) {return createElement('div', { class: 'profile-card error' }, `Error: ${error.message}`);}if (!user) {return createElement('div', { class: 'profile-card empty' }, 'No user data.');}return createElement('div', { class: 'profile-card' },createElement('img', { class: 'avatar', src: user.avatar }),createElement('h2', { class: 'name' }, user.name),createElement('p', { class: 'bio' }, user.bio),createElement('button',{class: `follow-btn ${isFollowing ? 'following' : ''}`,onClick: onFollow // 直接調用從props傳來的回調},isFollowing ? 'Following' : 'Follow'));
}module.exports = { UserProfileDisplay };
看看這個組件有多么“純粹”:
- 它不包含任何
fetch
、setTimeout
或任何異步邏輯。 - 它沒有自己的
state
。所有的動態數據(isLoading
,error
,user
…)都來自props
。 - 它完全不知道這些數據從何而來,也不知道點擊“Follow”按鈕后會發生什么。它只是一個忠實的“渲染仆人”。
- 你可以輕易地為它編寫測試:只需傳入不同的
props
組合,然后斷言返回的VNode結構是否符合預期。
步驟二:創建“聰明”的UserProfileContainer
組件
接下來,是我們的“操偶師”——容器組件。它將負責所有的臟活累活。我們將用一個Class來模擬它,因為它需要管理內部狀態(比如isLoading
)。
containers/UserProfileContainer.js
// CSDN @ 你的用戶名
// 系列: 前端內功修煉:從零構建一個“看不見”的應用
//
// 文件: /src/v7/containers/UserProfileContainer.js
// 描述: 一個容器組件,負責用戶資料的業務邏輯。const { createElement } = require('../../v3/createElement');
const { UserProfileDisplay } = require('../presentational/UserProfileDisplay');
const { fetchUserData, followUserApi } = require('../api'); // 模擬的API服務class UserProfileContainer {constructor(props) {this.props = props;// 容器組件管理著所有的狀態this.state = {isLoading: true,error: null,user: null,isFollowing: false};// (在真實React中,這些會由生命周期方法和事件處理器處理)// 這里我們手動調用來模擬流程this._loadUserData();}// 模擬setStatesetState(newState) {this.state = { ...this.state, ...newState };console.log('[Container] State changed:', this.state);// 在真實應用中,setState會觸發重新渲染// 這里我們可以想象 render() 會被再次調用}// --- 業務邏輯 ---async _loadUserData() {try {const user = await fetchUserData(this.props.userId);this.setState({ user, isLoading: false });} catch (error) {this.setState({ error, isLoading: false });}}_handleFollow = async () => {// 防止重復點擊if (this.state.isFollowing) return;console.log('[Container] Handling follow action...');try {await followUserApi(this.props.userId);this.setState({ isFollowing: true });} catch (err) {console.error('Failed to follow user', err);// 可以在這里設置一個短暫的錯誤提示狀態}}/*** render方法的核心:* 1. 準備好所有的props* 2. 渲染展示組件* 3. 把props傳遞下去*/render() {console.log('[Container] Rendering UserProfileDisplay with props:', {...this.state,onFollow: 'a function' // 打印時簡化函數});return createElement(UserProfileDisplay, {...this.state, // 將所有狀態作為props傳遞onFollow: this._handleFollow, // 將邏輯處理函數作為回調傳遞});}
}module.exports = { UserProfileContainer };
分析這個容器組件:
- 它不包含任何具體的HTML標簽(除了在
createElement
中調用了UserProfileDisplay
)。它的UI完全委托給了展示組件。 - 它管理著所有與業務相關的狀態:
isLoading
,error
,user
,isFollowing
。 - 它負責調用
fetchUserData
和followUserApi
這兩個API,處理異步邏輯和錯誤。 - 最關鍵的是它的
render
方法:它做的唯一一件事就是渲染UserProfileDisplay
組件,并把自己的state
和_handleFollow
方法作為props
傳遞下去。
步驟三:組裝與運行
最后,我們創建一個入口文件來“運行”我們的容器組件。
main.js
// CSDN @ 你的用戶名
// 系列: 前端內功修煉:從零構建一個“看不見”的應用
//
// 文件: /src/v7/main.js
// 描述: 組裝并“渲染”我們的組件。const { renderToString } = require('../../v3/render');
const { UserProfileContainer } = require('./containers/UserProfileContainer');
const { createElement } = require('../../v3/createElement');// 模擬API
jest.mock('./api', () => ({fetchUserData: jest.fn().mockResolvedValue({avatar: 'avatar.png',name: 'Dan Abramov',bio: 'Working on @reactjs. The demo king.'}),followUserApi: jest.fn().mockResolvedValue({ success: true })
}));async function main() {console.log('--- Initial Render ---');// 1. 實例化容器組件const app = new UserProfileContainer({ userId: 'dan_abramov' });// 2. 第一次render (isLoading: true)let vnode = app.render();// 注意:我們的createElement現在支持函數作為type// 為了渲染,我們需要“執行”這個函數組件來獲取真正的VNodeif (typeof vnode.type === 'function') {vnode = vnode.type(vnode.props);}console.log('\n--- HTML Output (Loading State) ---');console.log(renderToString(vnode)); // 輸出 loading...// 3. 模擬異步數據加載完成await new Promise(resolve => setTimeout(resolve, 100)); // 等待API調用// 4. 第二次render (isLoading: false, user: data)vnode = app.render();if (typeof vnode.type === 'function') {vnode = vnode.type(vnode.props);}console.log('\n--- HTML Output (Success State) ---');console.log(renderToString(vnode)); // 輸出用戶信息// 5. 模擬點擊關注按鈕console.log('\n--- Simulating Follow Click ---');// 在真實DOM中,onClick會綁定這個函數await app._handleFollow(); // 6. 第三次render (isFollowing: true)vnode = app.render();if (typeof vnode.type === 'function') {vnode = vnode.type(vnode.props);}console.log('\n--- HTML Output (Following State) ---');console.log(renderToString(vnode)); // 輸出 "Following" 按鈕
}main();
通過這個模擬流程,我們可以看到清晰的分工:UserProfileContainer
負責在不同的狀態間切換(loading -> success -> following),而UserProfileDisplay
則忠實地根據傳遞下來的props
渲染出對應的UI。
結論:分離帶來的巨大收益
我們為什么要費這么大勁,把一個組件拆成兩個?這種分離模式,給我們帶來了不可估量的好處:
-
極致的可復用性:
UserProfileDisplay
成了一個UI“萬金油”。我們可以用另一個完全不同的容器組件(比如MyProfileContainer
,它從本地localStorage讀取數據)來包裹它,實現不同的業務邏輯,而UI保持一致。我們也可以在項目的故事書(Storybook)中,獨立地測試和展示UserProfileDisplay
的各種UI狀態。 -
清晰的關注點:設計師和對UI/UX更感興趣的前端工程師,可以專注于
presentational
目錄下的組件,他們不需要關心任何業務邏輯。而負責業務邏輯和數據流的工程師,可以專注于containers
,他們不需要寫太多的HTML/CSS。這促進了團隊內部的協作。 -
驚人的可測試性:測試
UserProfileDisplay
變得極其簡單,它就是個純函數,輸入props
,斷言輸出的VNode。測試UserProfileContainer
也變得更聚焦,你可以模擬props
,然后斷言它的內部state
變化是否正確,或者它是否調用了正確的API,而無需關心它到底渲染了什么DOM。 -
邏輯與視圖解耦:這是最重要的。當應用的業務邏輯需要重構時(比如從REST API遷移到GraphQL),你可能只需要修改容器組件,而所有的展示組件都無需改動。反之,當應用需要進行UI改版時,你只需修改展示組件,容器組件可以保持不變。
但是,這個模式是銀彈嗎?
不是。隨著React Hooks的出現,函數組件也能輕松地管理狀態和副作用。組件的邏輯部分可以通過自定義Hooks(Custom Hooks)來抽離,這使得“容器/展示”的分離不再是唯一選擇,甚至在某些場景下顯得有些“過度設計”。
然而,這種分離的思想——將“如何工作”與“如何展示”解耦——是永恒的。無論你用的是類組件、Hooks還是其他框架,理解了這個核心思想,你就能寫出更清晰、更健壯、更易于維護的組件。
在下一章 《組件化(二):Hook的本質:一個優雅的“副作用”管理模式》 中,我們將深入探討React Hooks是如何從根本上改變組件邏輯復用方式的。我們將親手模擬一個useState
和useEffect
的實現,揭示其在閉包和鏈表之上構建的優雅設計,看看它是如何成為比“容器/展示”模式更輕量、更靈活的邏輯分離方案的。敬請期待!