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

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

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

好的组件设计和封装是一切的基础,基于这以上构建出的各种工程化方案全局状态管理,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/






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


相关文章
|
4月前
|
缓存 前端开发 JavaScript
第三章(概念篇) 微前端架构模式
第三章(概念篇) 微前端架构模式
104 0
|
10月前
|
存储 Cloud Native Linux
软件开发方法:复用与扩展
软件开发方法:复用与扩展
|
12月前
|
前端开发 JavaScript API
构建可重用用户界面:深入了解组件库的价值与实践
在现代应用程序开发中,组件库已经成为加速开发和提高代码质量的利器。它们是可重用的UI构建块,可以帮助开发者创建一致、漂亮和功能强大的用户界面。本博客将深入研究组件库的核心概念、最佳实践以及为什么它们对于现代开发至关重要。
71 0
|
23天前
|
存储 SQL 缓存
深入浅出:构建高效后端服务的五大原则
在数字化浪潮中,后端服务作为技术架构的核心,承载着数据处理和业务逻辑的重要任务。本文将深入探讨如何构建一个高效、稳定且可扩展的后端服务,从五个关键原则出发,带领读者一步步理解并实践这些原则,以确保后端系统能够灵活应对各种挑战。
|
20天前
|
前端开发 C# 设计模式
“深度剖析WPF开发中的设计模式应用:以MVVM为核心,手把手教你重构代码结构,实现软件工程的最佳实践与高效协作”
【8月更文挑战第31天】设计模式是在软件工程中解决常见问题的成熟方案。在WPF开发中,合理应用如MVC、MVVM及工厂模式等能显著提升代码质量和可维护性。本文通过具体案例,详细解析了这些模式的实际应用,特别是MVVM模式如何通过分离UI逻辑与业务逻辑,实现视图与模型的松耦合,从而优化代码结构并提高开发效率。通过示例代码展示了从模型定义、视图模型管理到视图展示的全过程,帮助读者更好地理解并应用这些模式。
35 0
|
21天前
|
前端开发 API 开发者
【前端数据革命】React与GraphQL协同工作:从理论到实践全面解析现代前端数据获取的新范式,开启高效开发之旅!
【8月更文挑战第31天】本文通过具体代码示例,介绍了如何利用 GraphQL 和 React 搭建高效的前端数据获取系统。GraphQL 作为一种新型数据查询语言,能精准获取所需数据、提供强大的类型系统、统一的 API 入口及实时数据订阅功能,有效解决了 RESTful API 在复杂前端应用中遇到的问题。通过集成 Apollo Client,React 应用能轻松实现数据查询与实时更新,大幅提升性能与用户体验。文章详细讲解了从安装配置到查询订阅的全过程,并分享了实践心得,适合各层次前端开发者学习参考。
27 0
|
4月前
|
算法 测试技术 数据处理
【C++ 设计思路】优化C++项目:高效解耦库接口的实战指南
【C++ 设计思路】优化C++项目:高效解耦库接口的实战指南
147 5
|
4月前
|
消息中间件 开发者 微服务
构建高效代码:模块化设计原则的实践与思考
在软件开发的世界中,编写可维护、可扩展且高效的代码是每个开发者追求的目标。本文将探讨如何通过应用模块化设计原则来提升代码质量,分享一些实践中的经验教训以及对未来技术趋势的思考。
|
11月前
|
设计模式 网络协议 Java
《移动互联网技术》 第十章 系统与通信: 掌握Android系统的分层架构设计思想和基于组件的设计模式
《移动互联网技术》 第十章 系统与通信: 掌握Android系统的分层架构设计思想和基于组件的设计模式
95 0
|
存储 前端开发 安全
Controller层代码技巧,开发人员可以编写出更高效、可维护的代码
Controller层代码技巧,开发人员可以编写出更高效、可维护的代码
146 0