高效组件的设计与封装之道

简介: 本文结合了作者自身碰到的场景来说明如何做好组件设计和封装。

好的组件设计和封装是一切的基础

好的组件设计和封装是一切的基础,基于这以上构建出的各种工程化方案全局状态管理,React.memoReact.useMemoReact.useCallback都不是必须的,他们保证的是即使没有做好设计也能保证项目的下限,但保证不了他的扩展性。


设计包含什么

image.png


我们沿着各个分支走一遍,结合一些我自身的碰到的场景来说明。


基础组件 / 业务组件

这个很好理解,我们开发中会碰到各种基础组件和业务组件,我们如何区分他们的差别。


在我们的开发中除开对 UI 有特定要求的产品,基本 Antd 作为了我们的基础组件,通用性是我们区分基础组件和业务组件的边界。


下方这张图我觉得较好的区分了他们,越往左通用性越高,越往右定制化越强。就像产品需要定位使用人群,组件一样需要定义使用的范围,我们的组件最后总应该给他们归属为下图中的一部分。

image.png

图源:Josh W Comeau 的 React

课程:https://www.joyofreact.com/


业务组件专门为业务服务,也需要带入基础组件通用的思维去考虑,尽量地增加他的扩展性。


在刷软件的时候看到的一个场景,关于一个提交按钮的组件。

const SubmitButton = (props) => {
  return <buttton>{props.buttonText}</buttton>
}

const SubmitButton = (props) => {
  return <buttton>{props.isAdd ? '新增' : '编辑'}</buttton>
}

前者定位是一个通用组件,按钮的文本直接通过外部调用方来确定的。这里会有一个问题:


  • 扩展性问题。按上述设计,如果后续该业务需要增加草稿功能,可能会传入第二个状态,isDraft来定义保存草稿的文案,随着业务的发展,后续的业务增加都需要起码修改两个文件,外部的编辑页组件和这个提交按钮组件。


通用特性 / 定制特性

基于上面的问题,我们再思考一个问题,既然通用性是基础组件和业务组件的分界线,我们就需要了解「通用特性」和「定制特性」,通用特性归于基础组件中,定制特性在业务组件中封装。


先回到前文提交按钮的例子:

// 对于按钮来说,按钮的文本是一个通用的特性
// 无论文本内容是什么,都不会影响按钮本身的 UI 特性
const BaseButton = (props) => {
  const { buttonText } = props;

  return <button>{buttonText}</button>
}

const SubmitButton = (props) => {
  const { buttonText, isAdd } = props;

  return <BaseButton buttonText={isAdd ? '新增' : '编辑'} />
}

UI 状态天然是通用特性,因为一个产品中,我们需要给用户提供一致的 UI 体验和操作,这不仅能体验产品的专业度也能形成用户对于产品交互的心智。


再来看一个复杂些的例子,各大厂都在推的工作流类应用,这是其中一个产品「Dify」的页面。

image.png

我们来思考如果是我们来做这些节点,我们应该如何来做?我们可以从以下几点来思考:

  1. 节点的通用特性是什么?
  2. 节点的定制特性是什么?
  3. 如何封装?

image.png

const NODE_MAP = {
  start: StartNode,
  llm: LLMNode,
  end: EndNode
}

const CustomNode = (props) => {
  const { type } = props;
  const RenderNode = NODE_MAP[type];

  return <BaseNode {...props}>
    <RenderNode />
  </BaseNode>
}

const BaseNode = (props) => {
  const { children, data, ...commonProperties } = props;

  const onNodeClick = () => {
    // TODO
  };

  return cloneElement(children, { data });
}


状态定义

状态的定义是一个见仁见智的问题,跟组件层级也有很多的关联。


最基础的原则我相信大家都知道,React 官方的这篇文章值得反复阅读。


https://react.dev/learn/thinking-in-react

DDD 举例

这是我个人推崇的状态定义方式:

  1. 区分「UI 状态」 和「业务状态」。
  2. 业务状态的组织借鉴后端 DDD 的思想,将状态归于某一个具体的业务领域。


DDD 是一种开发思想,并不是具体的框架和技巧,不同的语言框架也有不同的实现方法。


前端在开发中其实很少去做业务上的抽象和建模,但是这种思想仍然可以借鉴来组织状态,能在多变的业务中易于扩展和修改,也在倒逼我们必须去理解业务的核心包含哪些内容,我们必须在理解业务的基础上做设计。

https://en.wikipedia.org/wiki/Domain-driven_design


Page -> multi Bussiness Entiry -> compose UI components


接下来我们来看一个简单的表单例子:

image.png

上面按区块分,我们可能会按区块封装组件1,组件2。所有的联动和逻辑是按 UI 块封装的。如果我们需要去掉某些字段或者在某些字段里加逻辑都需要直接变更组件1或组件2。按 DDD 的思路走,我们需要拆分成表单父组件和业务组件1,2,3,4。核心是因为真实的世界里一个业务领域的变更总是在领域内发生,所以扩展更改都只会发生在组件内部和父组件内,不影响其他的组件,我们可以精准的评估影响面。


状态的存储

状态的存储方式是一个跟实际业务挂钩的东西,暂时没有什么特别要说的内容。


唯一要注意的就是区分好「 全局状态 」和 「 组件状态 」。


如果各位读者有什么好的方法论沉淀欢迎评论区讨论。


小技巧


内容提升

// before
const Parent = () => {
  const [name, setName] = useState('han');
  
  return (
    <div>
      <Child name={name}></Child>
    </div>
  );
}

const Child = (props) => {
  return (
    <SubChild name={props.name}/>
  );
}

const SubChild = ({ name }) => {
  // TODO
}

// 我们注意到 Child 这个组件他只是透传了 name 字段给 SubChild,他本身并没有使用 name。
// 这在结构上会在后续扩展上造成影响,而且存在多层的情况下就会导致 参数透传地狱
// 我们如果需要进行优化,有哪几种方案呢,React 的优化说到底只有两个方案,组件层级和状态管理

// 1. 使用 Context
// 2. 使用第三方状态管理工具
// 3. 内容提升

// 前两种都是将状态提升到一个更高的纬度,是一种相对来讲绕过的方案。
// 他们也是很好的解决方案。
// 内容提升是改变组件层级来达到同样的目的。


// after
const Parent = () => {
  const [name, setName] = useState('han');
  
  return (
    <div>
      <Child>
        <SubChild name={name} />
      </Child>
    </div>
  );
}

const Child = ({ children }) => {
  return (
    {children}
  );
}

const SubChild = ({ name }) => {
  // TODO
}

我们需要区分两个概念,「DOM 结构」「React 的层级结构」。就像我们需要区分「组件渲染」「DOM 变更」是两件事。


这里面的优化原理如果你熟悉 React 的渲染原理,可以很轻易的理解,这里我们就不展开介绍了,如果你暂时还不理解,强烈推荐你花费 1 - 2 小时阅读并自己消化下这篇文章[1]。


控制子组件的最小权限

只提供对应功能的修改给特定的组件,代替传递 setXXX

// bed
const Parent = () => {
  const [state, setState] = useState({
    name: 'han',
    sex: 'man'
  });

  return (
    <div>
      <section>
      name: {name}
      </section>
      <section>
        sex: {sex}
      </section>
      <ChangeNameForm setState={setState} />
    </div>
  )
}


// good
const Parent = () => {
  const [state, setState] = useState({
    name: 'han',
    sex: 'man'
  });

  const handleChangeName = (name) => {
    setState({ ...state, name });
  }

  return (
    <div>
      <section>
      name: {name}
      </section>
      <section>
        sex: {sex}
      </section>
      <ChangeNameForm handleChangeName={handleChangeName} />
    </div>
  )
}

在我们的示例中,这两者没有明显的区别,但在我们云动的真实场景中,state 可能十分复杂,我们可能需要直接修改 ChangeNameForm 组件。


这样的写法一方面是将状态变更都提升到了,遵循了单一数据流。另一方面是限定了子组件的功能,也语义化了功能,我们不会再去理解整个大的 state


memo 相关

memo 也是我们在做组件设计的时候需要考虑的一个点,一些我们可预料的昂贵的渲染应该被优化掉。


从一个例子来思考

const Parent = (props) => {
  return <div>
    <Child name={props.name} />
    <MemoChild age={props.age} />
  </div>
}

const Child = (props) => {
  // 父组件渲染,每次都会渲染
  return <div>{props.name}</div>
}

const MemoChild = memo((props) => {
  父组件的 name 属性不变的情况下,不会重渲染
  return <div>{props.name}</div>
})

如果 Child 是一个渲染十分耗时的子组件,那多次的重渲染就会对性能造成影响,如果还是可以多次添加的子组件,那性能就会呈现一个线性增长,如果有 10 个就会导致 10 倍的卡顿。


性能是一个经常被提到的点,如果只是单个节点,硬件越来越好的情况下可能会被我们忽略,毕竟我们很难界定去说 memo 的 diff 和一个子组件的渲染相比到底谁更昂贵,在编码层面起码很难界定,需要依赖性能分析的报告。React 官方也推荐当我们真的遇到性能问题的时候再去分析到底哪里出了问题。


我想说的另一个点是被 memo 大部分情况下总被人冠以性能优化下的本质,他的本质是减少组件渲染,在一些特定业务情况下它必须被使用。


我们来思考这样一个场景:


我们有一个父组件,卡片有 hover,select 等交互状态,卡片内有个输出面板组件,面板中存在一个唯一ID,当我移入移出的时候会发生什么?

const Card = () => {
  const [otherState, setOtherState] = useState(0);
  const [outputText, setOutputText] = useState(
    "大家好,这是一个在特定场景下必须需要使用 memo 的示例,欢迎大家评论沟通想法"
  );

  return (
    <div
      onMouseLeave={() => setOtherState((pre) => ++pre)}
      style={{ background: "#ddd" }}
    >
      <OutputPanel text={outputText} />
    </div>
  );
};

const OutputPanel = (outputProps: { text: string }) => {
  return (
    <div>
      <div>{outputProps.text}</div>
      <div>唯一ID:{Math.floor(Math.random() * 100)}</div>
    </div>
  );
};

我们会发现每当我们移出内容框时,我们的唯一 ID 变了。


https://codesandbox.io/p/sandbox/memo-dui-ye-wu-de-ying-xiang-d749ph?file=%2Fsrc%2FApp.tsx%3A5%2C1-28%2C3


一个组件的优化过程

参考文章[2]。


归纳总结

应该始终以一个消费者的视角来开发组件。


综上所述,组件的设计应该包含以下的路径:

  1. 根据「 通用特性/定制特性 」确定组件的通用级别。
  2. 将特性区分为 「 UI 特性 / 业务特性 」来确定组件层级和封装。
  3. 结合一些技巧来优化组件层级和状态存储的位置,优化性能表现。
  4. 还有许多的细节需要处理(Typescript 定义、props 定义、样式等)...

参考资料:

1、

https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/#standard-render-behavior

2、

https://www.developerway.com/posts/components-composition-how-to-get-it-right3、

https://courses.joshwcomeau.com/






来源  |  阿里云开发者公众号
作者  |  寒誉


相关文章
|
9月前
|
安全 Java 数据安全/隐私保护
|
30天前
|
机器学习/深度学习 人工智能 自然语言处理
《解锁ArkTS模型封装与抽象:代码复用与维护的进阶之道》
在鸿蒙系统中使用ArkTS开发时,高效管理和运用AI模型至关重要。通过封装和抽象,隐藏模型实现细节并提供简洁接口,能提升代码复用性、稳定性和可扩展性。封装使模型内部变化不影响外部调用,降低耦合度;抽象提取共性操作,简化代码结构。这不仅提高开发效率,还增强代码可维护性和团队协作效率,为复杂智能应用奠定基础。
53 21
|
9月前
|
前端开发 数据处理 API
后端开发:构建稳健与高效的服务器逻辑
后端开发:构建稳健与高效的服务器逻辑
|
3月前
|
敏捷开发 缓存 中间件
.NET技术的高效开发模式,涵盖面向对象编程、良好架构设计及高效代码编写与管理三大关键要素
本文深入探讨了.NET技术的高效开发模式,涵盖面向对象编程、良好架构设计及高效代码编写与管理三大关键要素,并通过企业级应用和Web应用开发的实践案例,展示了如何在实际项目中应用这些模式,旨在为开发者提供有益的参考和指导。
52 3
|
5月前
|
设计模式 存储 人工智能
深度解析Unity游戏开发:从零构建可扩展与可维护的游戏架构,让你的游戏项目在模块化设计、脚本对象运用及状态模式处理中焕发新生,实现高效迭代与团队协作的完美平衡之路
【9月更文挑战第1天】游戏开发中的架构设计是项目成功的关键。良好的架构能提升开发效率并确保项目的长期可维护性和可扩展性。在使用Unity引擎时,合理的架构尤为重要。本文探讨了如何在Unity中实现可扩展且易维护的游戏架构,包括模块化设计、使用脚本对象管理数据、应用设计模式(如状态模式)及采用MVC/MVVM架构模式。通过这些方法,可以显著提高开发效率和游戏质量。例如,模块化设计将游戏拆分为独立模块。
302 3
|
6月前
|
存储 SQL 缓存
深入浅出:构建高效后端服务的五大原则
在数字化浪潮中,后端服务作为技术架构的核心,承载着数据处理和业务逻辑的重要任务。本文将深入探讨如何构建一个高效、稳定且可扩展的后端服务,从五个关键原则出发,带领读者一步步理解并实践这些原则,以确保后端系统能够灵活应对各种挑战。
|
8月前
|
存储 缓存 Linux
【实战指南】嵌入式RPC框架设计实践:六大核心类构建高效RPC框架
在先前的文章基础上,本文讨论如何通过分层封装提升一个针对嵌入式Linux的RPC框架的易用性。设计包括自动服务注册、高性能通信、泛型序列化和简洁API。框架分为6个关键类:BindingHub、SharedRingBuffer、Parcel、Binder、IBinder和BindInterface。BindingHub负责服务注册,SharedRingBuffer实现高效数据传输,Parcel处理序列化,而Binder和IBinder分别用于服务端和客户端交互。BindInterface提供简单的初始化接口,简化应用集成。测试案例展示了客户端和服务端的交互,验证了RPC功能的有效性。
506 11
|
5月前
|
缓存 负载均衡 数据管理
深入探索微服务架构的核心要素与实践策略在当今软件开发领域,微服务架构以其独特的优势和灵活性,已成为众多企业和开发者的首选。本文将深入探讨微服务架构的核心要素,包括服务拆分、通信机制、数据管理等,并结合实际案例分析其在不同场景下的应用策略,旨在为读者提供一套全面、深入的微服务架构实践指南。**
**微服务架构作为软件开发领域的热门话题,正引领着一场技术革新。本文从微服务架构的核心要素出发,详细阐述了服务拆分的原则与方法、通信机制的选择与优化、数据管理的策略与挑战等内容。同时,结合具体案例,分析了微服务架构在不同场景下的应用策略,为读者提供了实用的指导和建议。
|
6月前
|
前端开发 API 开发者
【前端数据革命】React与GraphQL协同工作:从理论到实践全面解析现代前端数据获取的新范式,开启高效开发之旅!
【8月更文挑战第31天】本文通过具体代码示例,介绍了如何利用 GraphQL 和 React 搭建高效的前端数据获取系统。GraphQL 作为一种新型数据查询语言,能精准获取所需数据、提供强大的类型系统、统一的 API 入口及实时数据订阅功能,有效解决了 RESTful API 在复杂前端应用中遇到的问题。通过集成 Apollo Client,React 应用能轻松实现数据查询与实时更新,大幅提升性能与用户体验。文章详细讲解了从安装配置到查询订阅的全过程,并分享了实践心得,适合各层次前端开发者学习参考。
54 0
|
9月前
|
算法 测试技术 数据处理
【C++ 设计思路】优化C++项目:高效解耦库接口的实战指南
【C++ 设计思路】优化C++项目:高效解耦库接口的实战指南
229 5