组件化(一):重新思考“组件”:状态、视图和逻辑的“最佳”分离实践

组件化(一):重新思考“组件”:状态、视图和逻辑的“最佳”分离实践

引子:组件的“内忧”与“外患”

至此,我们的前端内功修炼之旅已经硕果累累。我们掌握了组件化的架构思想,拥有了高效的渲染引擎,还探索了从集中式到原子化的两种状态管理模式。我们似乎已经拥有了构建任何复杂应用所需的全套“武器”。

但魔鬼往往藏在细节中。今天,我们要将视线从宏观的架构和状态管理,拉回到我们日常工作中接触最频繁的单元——**组件(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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈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实现一键式容器化部署到阿里云服务器。文…