組件化(一):重新思考“組件”:狀態、視圖和邏輯的“最佳”分離實踐

組件化(一):重新思考“組件”:狀態、視圖和邏輯的“最佳”分離實踐

引子:組件的“內憂”與“外患”

至此,我們的前端內功修煉之旅已經碩果累累。我們掌握了組件化的架構思想,擁有了高效的渲染引擎,還探索了從集中式到原子化的兩種狀態管理模式。我們似乎已經擁有了構建任何復雜應用所需的全套“武器”。

但魔鬼往往藏在細節中。今天,我們要將視線從宏觀的架構和狀態管理,拉回到我們日常工作中接觸最頻繁的單元——**組件(Component)**本身。

我們每天都在寫組件,但我們是否真正思考過:一個“好”的組件,應該是什么樣的?

想象一個常見的UserProfile組件,它需要:

  1. 根據userId從API獲取用戶數據。
  2. 在獲取數據時,顯示一個加載中的Spinner
  3. 數據獲取成功后,顯示用戶的頭像、姓名、簡介。
  4. 如果獲取失敗,顯示一條錯誤信息。
  5. 用戶點擊“關注”按鈕時,調用另一個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 };

看看這個組件有多么“純粹”:

  • 它不包含任何fetchsetTimeout或任何異步邏輯。
  • 它沒有自己的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
  • 它負責調用fetchUserDatafollowUserApi這兩個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。

結論:分離帶來的巨大收益

我們為什么要費這么大勁,把一個組件拆成兩個?這種分離模式,給我們帶來了不可估量的好處:

  1. 極致的可復用性UserProfileDisplay成了一個UI“萬金油”。我們可以用另一個完全不同的容器組件(比如MyProfileContainer,它從本地localStorage讀取數據)來包裹它,實現不同的業務邏輯,而UI保持一致。我們也可以在項目的故事書(Storybook)中,獨立地測試和展示UserProfileDisplay的各種UI狀態。

  2. 清晰的關注點:設計師和對UI/UX更感興趣的前端工程師,可以專注于presentational目錄下的組件,他們不需要關心任何業務邏輯。而負責業務邏輯和數據流的工程師,可以專注于containers,他們不需要寫太多的HTML/CSS。這促進了團隊內部的協作。

  3. 驚人的可測試性:測試UserProfileDisplay變得極其簡單,它就是個純函數,輸入props,斷言輸出的VNode。測試UserProfileContainer也變得更聚焦,你可以模擬props,然后斷言它的內部state變化是否正確,或者它是否調用了正確的API,而無需關心它到底渲染了什么DOM。

  4. 邏輯與視圖解耦:這是最重要的。當應用的業務邏輯需要重構時(比如從REST API遷移到GraphQL),你可能只需要修改容器組件,而所有的展示組件都無需改動。反之,當應用需要進行UI改版時,你只需修改展示組件,容器組件可以保持不變。

但是,這個模式是銀彈嗎?

不是。隨著React Hooks的出現,函數組件也能輕松地管理狀態和副作用。組件的邏輯部分可以通過自定義Hooks(Custom Hooks)來抽離,這使得“容器/展示”的分離不再是唯一選擇,甚至在某些場景下顯得有些“過度設計”。

然而,這種分離的思想——將“如何工作”與“如何展示”解耦——是永恒的。無論你用的是類組件、Hooks還是其他框架,理解了這個核心思想,你就能寫出更清晰、更健壯、更易于維護的組件。

在下一章 《組件化(二):Hook的本質:一個優雅的“副作用”管理模式》 中,我們將深入探討React Hooks是如何從根本上改變組件邏輯復用方式的。我們將親手模擬一個useStateuseEffect的實現,揭示其在閉包和鏈表之上構建的優雅設計,看看它是如何成為比“容器/展示”模式更輕量、更靈活的邏輯分離方案的。敬請期待!

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/web/91177.shtml
繁體地址,請注明出處:http://hk.pswp.cn/web/91177.shtml
英文地址,請注明出處:http://en.pswp.cn/web/91177.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

【Redis】Redis 協議與連接

一、Redis 協議 1.1 RESP RESP 是 Redis 客戶端與服務器之間的通信協議,采用文本格式(基于 ASCII 字符),支持多種數據類型的序列化和反序列化 RESP 通過首字符區分數據類型,主要支持 5 種類型: 類型首字…

Android通知(Notification)全面解析:從基礎到高級應用

一、Android通知概述通知(Notification)是Android系統中用于在應用之外向用戶傳遞信息的重要機制。當應用需要告知用戶某些事件或信息時,可以通過通知在狀態欄顯示圖標,用戶下拉通知欄即可查看詳細信息。這種機制幾乎被所有現代應用采用,用于…

VUE3(四)、組件通信

1、props作用&#xff1a;子組件之間的通信。父傳子&#xff1a;屬性值的非函數。子傳父&#xff1a;屬性值是函數。父組件&#xff1a;<template><div>{{ childeData }}</div>——————————————————————————————<child :pare…

【數據結構與算法】數據結構初階:詳解二叉樹(六)——二叉樹應用:二叉樹選擇題

&#x1f525;個人主頁&#xff1a;艾莉絲努力練劍 ?專欄傳送門&#xff1a;《C語言》、《數據結構與算法》、C語言刷題12天IO強訓、LeetCode代碼強化刷題 &#x1f349;學習方向&#xff1a;C/C方向 ??人生格言&#xff1a;為天地立心&#xff0c;為生民立命&#xff0c;為…

Android廣播實驗

【實驗目的】了解使用Intent進行組件通信的原理&#xff1b;了解Intent過濾器的原理和匹配機制&#xff1b;掌握發送和接收廣播的方法【實驗內容】任務1、普通廣播&#xff1b;任務2、系統廣播&#xff1b;任務3、有序廣播&#xff1b;【實驗要求】1、練習使用靜態方法和動態方…

html轉word下載

一、插件使用//轉html為wordnpm i html-docx-js //保存文件到本地npm i file-saver 注&#xff1a;vite 項目使用esm模式會報錯&#xff0c;with方法錯誤&#xff0c;修改如下&#xff1a;//直接安裝修復版本npm i html-docx-fixed二、封裝導出 exportWord.jsimport htmlDocx f…

北方公司面試記錄

避免被開盒&#xff0c;先稱之為“北方公司”&#xff0c;有確定結果后再更名。 先說流程&#xff0c;線下面試&#xff0c;時間非常急&#xff0c;下午兩點鐘面試&#xff0c;中午十二點打電話讓我去&#xff0c;帶兩份紙質簡歷。 和一般的菌工單位一樣&#xff0c;先在傳達室…

linux——ps命令

PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND0 1 1 1 ? -1 Ss 0 0:01 /usr/lib/systemd/systemd1 123 123 123 ? -1 S 0 0:00 /usr/sbin/sshd -D123 456 456 456 pts/0 456 R 10…

C#.NET 依賴注入詳解

一、是什么 在 C#.NET 中&#xff0c;依賴注入&#xff08;Dependency Injection&#xff0c;簡稱 DI&#xff09; 是一種設計模式&#xff0c;用于實現控制反轉&#xff08;Inversion of Control&#xff0c;IoC&#xff09;&#xff0c;以降低代碼耦合、提高可測試性和可維護…

Vue監視數據的原理和set()的使用

在 Vue 中&#xff0c;Vue.set()&#xff08;或 this.$set()&#xff09;是用于解決響應式數據更新檢測的重要方法&#xff0c;其底層與 Vue 的數據監視原理緊密相關。以下從使用場景和實現原理兩方面詳細說明&#xff1a;一、Vue.set () 的使用場景與用法1. 為什么需要 Vue.se…

在 Vue 中,如何在回調函數中正確使用 this?

在 Vue 組件中&#xff0c;this 指向當前組件實例&#xff0c;但在回調函數&#xff08;如定時器、異步請求、事件監聽等&#xff09;中&#xff0c;this 的指向可能會丟失或改變&#xff0c;導致無法正確訪問組件的屬性和方法。以下是在回調函數中正確使用 this 的幾種常見方式…

第4章唯一ID生成器——4.4 基于數據庫的自增主鍵的趨勢遞增的唯一ID

基于數據庫的自增主鍵也可以生成趨勢遞增的唯一 ID&#xff0c;且由于唯一ID不與時間戳關聯&#xff0c;所以不會受到時鐘回撥問題的影響。 4.4.1 分庫分表架構 數據庫一般都支持設置自增主鍵的初始值和自增步長&#xff0c;以MySQL為例&#xff0c;自增主鍵的自增步長由auto_i…

設計模式:Memento 模式詳解

Memento 模式詳解Memento&#xff08;備忘錄&#xff09;模式是一種行為型設計模式&#xff0c;用于在不破壞封裝性的前提下&#xff0c;捕獲并外部化一個對象的內部狀態&#xff0c;以便在之后能夠將該對象恢復到原先保存的狀態。它廣泛應用于需要實現撤銷&#xff08;Undo&am…

數據結構(6)單鏈表算法題(下)

一、環形鏈表Ⅰ 1、題目描述 https://leetcode.cn/problems/linked-list-cycle 2、算法分析 思路&#xff1a;快慢指針 根據上圖所示的流程&#xff0c;我們可以推測出這樣一個結論&#xff1a;若鏈表帶環&#xff0c;快慢指針一定會相遇。 那么&#xff0c;這個猜測是否正…

智能制造,從工廠建模,工藝建模,柔性制造,精益制造,生產管控,庫存,質量等多方面講述智能制造的落地方案。

智能制造&#xff0c;從工廠建模&#xff0c;工藝建模&#xff0c;柔性制造&#xff0c;精益制造&#xff0c;生產管控&#xff0c;庫存&#xff0c;質量等多方面講述智能制造的落地方案。

Qt 分裂布局:QSplitter 使用指南

在 GUI 開發中&#xff0c;高效管理窗口空間是提升用戶體驗的關鍵。QSplitter 作為 Qt 的核心布局組件&#xff0c;讓動態分割窗口變得簡單直觀。一、QSplitter 核心功能解析 QSplitter 是 Qt 提供的布局管理器&#xff0c;專用于創建可調節的分割區域&#xff1a; 支持水平/垂…

R語言與作物模型(DSSAT模型)技術應用

R語言在DSSAT模型的氣候、土壤、管理措施等數據準備&#xff0c;自動化模擬和結果分析上都發揮著重要的作用。一&#xff1a;DSSAT模型的高級應用 1.作物模型的概念 2.DSSAT模型發展現狀 3.DSSAT與R語言的安裝 4.DSSAT模型的高級應用案例 5.R語言在作物模型參數優化中的應用 6.…

JavaSE:學習輸入輸出編寫簡單的程序

一、打印輸出到屏幕 Java提供了三種核心輸出方法&#xff0c;適合不同場景&#xff1a; System.out.println() 打印內容后 自動換行 System.out.println("Welcome"); System.out.println("to ISS"); // 輸出&#xff1a; // Welcome // to ISSSystem.out…

訪問者模式感悟

訪問者模式 首先有兩個東西: 一個是訪問者vistor (每一個訪問者類都代表了一類操作) 一個是被訪問者entity (model /info/pojo/node等等這些都行)也就是是說是一個實體類 其操作方法被抽離給了其他類。 訪問者模式的核心思想就是**“把操作從數據結構中分離出來,每種操作…

從零到部署:基于Go和Docker的全棧短鏈接服務實戰(含源碼)

摘要&#xff1a;本文將手把手帶你使用Go語言&#xff0c;并遵循依賴倒置、分層架構等最佳實踐&#xff0c;構建一個高性能、高可用的全棧短鏈接生成器。項目采用Echo框架、GORM、Redis、MySQL&#xff0c;并通過Docker和Docker Compose實現一鍵式容器化部署到阿里云服務器。文…