Skip to main content

React 世界的一等公民 - 组件

· 25 分钟阅读

Choerodon猪齿鱼平台使用 React 作为前端应用框架,对前端的展示做了一定的封装和处理,并配套提供了前端组件库Choerodon UI。结合实际业务情况,不断对组件优化设计,提高代码质量。

本文将结合Choerodon猪齿鱼平台使用案例,简单说明组件的分类、设计原则和设计模式,帮助开发者在不同场景下选择正确的设计和方案编写组件(示例代码基于ES6/ES7的语法,适于有一定前端基础的读者)。

文章的主要内容包括:

  • React 组件简介
  • 组件分类
  • 组件设计原则、最佳实践
  • 组件设计模式简介

React 组件简介

React是指用于构建用户界面的 JavaScript 库。换言之,React是一个构建视图层的类库(或框架)。不管 React 本身如何复杂,不管其生态如何庞大,构建视图始终是它的核心。

可以用个公式说明:

UI = f(data)

React的基础原则有三条,分别是:

  1. React 界面完全由数据驱动;
  2. React 中一切都是组件;
  3. props 是 React 组件之间通讯的基本方式。

那么组件又是什么?

组件是一个函数或者一个 Class(当然 Class 也是 function),它根据输入参数,最终返回一个 React Element。简单地说,React Element 描述了“你想”在屏幕上看到的事物。抽象地说,React Element 元素是一个描述了 Dom Node 的对象。

所以实际上使用 React Component 来生成 React Element,对于开发体验有巨大的提升,比如不需要手写React.createElement等。

那么所有 React Component 都需要返回 React Element 吗?显然是不需要的。 return null; 或者返回其他的 React 组件都有存在的意义,它能完成并实现很多巧妙的设计、思想和副作用,在下文会有所扩展。

可以说,在 React 中一切皆为组件:

  • 用户界面就是组件;
  • 组件可以嵌套包装组成复杂功能;
  • 组件可以用来实现副作用。

React 也提供了多种编写组件的方法适用于各种场景实例。

组件分类

如何在场景下快速正确地选择组件设计模式和方案,首先得有一个自己接受和常用的组件分类,以便从分类中快速确定编写方法,再考虑设计模式等后续问题。

Vue的作者尤雨溪在一场Live中也表达过自己对前端组件的看法,“组件可以是函数,是有分类的。”从功能维度对组件进行了分类,这四种分类方式也适用于Choerodon猪齿鱼前端开发中的业务场景:

  • 纯展示型组件:数据进,DOM出,直观明了
  • 接入型组件:在React场景下的container
  • component,这种组件会跟数据层的service打交道,会包含一些跟服务器或者说数据源打交道的逻辑,container会把数据向下传递给展示型组件、
  • 交互型组件:典型的例子是对于表单组件的封装和加强,大部分的组件库都是以交互型组件为主,比如说Element UI,特点是有比较复杂的交互逻辑,但是是比较通用的逻辑,强调组件的复用
  • 功能型组件:以Vue的应用场景举例,路由的router-view组件、transition组件,本身并不渲染任何内容,是一个逻辑型的东西,作为一种扩展或者是抽象机制存在

在此以Choerodon猪齿鱼平台的一个创建界面来分析。

  • 红色布局:功能型组件
  • 蓝色菜单:交互型组件,菜单项:遍历菜单数据输出DOM的纯展示型组件
  • 右块内容:接入型组件(容器组件)
  • Table、btn等:交互型组件

可以看到,一个复杂界面可以分割成很多简单或复杂的组件,复杂组件还包括子组件等。此外,除了从功能维度对组件进行划分,也可以从开发者对组件的使用习惯进行分类(以下分类非对立关系):

  • 无状态组件
  • 有状态组件
  • 容器组件
  • 高阶组件
  • Render Callback组件

简单说明一下几种组件:

  • 无状态组件:无状态组件(Stateless Component)是最基础的组件形式,由于没有状态的影响所以就是纯静态展示的作用。基本组成结构就是属性(props)加上一个渲染函数(render)。由于不涉及到状态的更新,所以这种组件的复用性也最强。例如在各UI库中开发的按钮、输入框、图标等等。
  • 有状态组件:组件内部包含状态(state)且状态随着事件或者外部的消息而发生改变的时候,这就构成了有状态组件(Stateful Component)。有状态组件通常会带有生命周期(lifecycle),用以在不同的时刻触发状态的更新。在写业务逻辑时常用到,不同场景所用的状态和生命周期也会不同。
  • 容器组件:为使组件的职责更加单一,耦合性进一步地降低,引入了容器组件(Container Component)的概念。重要负责对数据获取以及处理的逻辑。下文的设计模式也会提到。
  • 高阶组件:“高阶组件(HoC)”也算是种组件设计模式。做为一个高阶组件,可以在原有组件的基础上,对其增加新的功能和行为。如打印日志,获取数据和校验数据等和展示无关的逻辑的时候,抽象出一个高阶组件,用以给基础的组件增加这些功能,减少公共的代码。
  • Render Callback组件:组件模式是在组件中使用渲染回调的方式,将组件中的渲染逻辑委托给其子组件。也是种重用组件逻辑的方式,也叫render props 模式。

以上这些组件编写模式基本上可以覆盖目前工作中所需要的模式。在写一些复杂的框架组件的时候,仔细设计和研究组件间的解耦和组合方式,能够使后续的项目可维护性大大增强。

对立的两大分类:

  • 基于类的组件:基于类的组件(Class based components)是包含状态和方法的。
  • 基于函数的组件:基于函数的组件(Functional Components)是没有状态和方法的。它们是纯粹的、易读的。尽可能的使用它们。

当然,React v16.7.0-alpha 中第一次引入了 Hooks 的概念,Hooks 的目的是让开发者不需要再用 class 来实现组件。这是React的未来,基于函数的组件也可处理状态。

了解了这些以后就需要有一个自己开发新组件前的思考,遵循组件设计原则,快速确定分类开始编写Code。

设计原则/最佳实践

React 的组件其实是软件设计中的模块,其设计原则也需遵从通用的组件设计原则,简单说来,就是要减少组件之间的耦合性(Coupling),让组件简单,这样才能让整体系统易于理解、易于维护。

即,设计原则:

  1. 接口小,props 数量少;
  2. 划分组件,充分利用组合(composition);
  3. 把 state 往上层组件提取,让下层组件只需要实现为纯函数。

就像搭积木,复杂的应用和组件都是由简单的界面和组件组成的。划分组件也没有绝对的方法,选择在当下场景合适的方式划分,充分利用组合即可。实际编写代码也是逐步精进的过程,努力做到:

  1. 功能正常;
  2. 代码整洁;
  3. 高性能。

取Choerodon猪齿鱼平台Devops项目的应用管理模块实例,导入应用:

这个界面看起来很简单,功能简介 + 导入步骤条,实际因为存在步骤条,内容很丰富。

首先组件叫做AppImport,组件内包含简介和步骤条,需要记录当前步骤条第几步状态’current‘,所以需要维持状态(state),可以肯定,AppImport 是一个有状态的组件,不能只是一个纯函数,而是一个继承自 Component 的类。

class AppImport extends React.Component {
constructor() {
super(...arguments);
this.state = {
current: 0,
};
}
render() {
//TODO: 返回所有JSX
}
}

接下来划分组件,按照数据边界来分割组件:

  • 使用了choerodon-front-boot 中定义好的容器组件,Page、Header、Content;
  • 渲染 Header,返回上级菜单,渲染当前界面title。
  • 渲染 Content,封装好的组件处理了导入应用和其详情简介;
  • 渲染 Steps 卡片,步骤条卡片渲染,state 为当前步以及后续需要导入提交的数据 data;
  • 最后,Steps 每一步数据需求都不同,均拆成单独子组件。

在 React 中,有一个误区,就是把 render 中的代码分拆到多个 renderXXXX 函数中去,比如下面这样:

class AppImport extends React.Component {
render() {
const Header = this.renderHeader();
const Content = this.renderContent();
const Steps = this.renderSteps();

return (
<Page>
{Header}
{Content}
{Steps}
</Page>
);
}

renderHeader() {
//TODO: 返回上级菜单,渲染当前界面title
}

renderContent() {
//TODO: 导入应用和其详情简介
}

renderSteps() {
//TODO: 返回步骤条卡片
}
}

用上面的方法组织代码,当然比写一个巨大的 render 函数要强,但是,实现这么多 renderXXXX 函数并不是一个明智之举,因为这些 renderXXXX 函数访问的是同样的 props 和 state,这样代码依然耦合在了一起。更好的方法是把这些 renderXXXX 重构成各自独立的 React 组件,像下面这样

class AppImport extends React.Component {
constructor() {
super(...arguments);
this.state = {
data: {},
current: 0,
};
}

next = () => {}

cancel = () => {}

render() {
return (
<Page>
<Header title:'xxx' backPath='xxxxxx' />
<Content code="app.import" values={{ appName }}>
<div className="c7n-app-import-wrap">
<Steps current={current} className="steps-line">
<Step key={item.key} title:{item.title} />
</Steps>
<div className="steps-content">
<Step0 onNext={this.next} onCancel={this.cancel} values={data} />
</div>
</div>
</Content>
</Page>
);
}
}

const Step = (props) => {
//TODO: 返回步骤条Content
};

const Steps = (props) => {
//TODO: Steps
};

const Page = (props) => {
//TODO: Page
}

// Header / Content

// 根据代码量,尽量每个组件都有自己专属的源代码文件 导出,再导入
// 示例代码中 Page、Header、Content 使用了choerodon-front-boot 中定义好的容器组件,
// Steps 使用了choerodon-ui 库
// 所以在头部导入即可
// import { Steps } from 'choerodon-ui';
// import { Content, Header, Page } from 'choerodon-front-boot';

实际情况下,步骤条不止一步,处理函数也不止那么简单,但是经过划分和抽取,作为展示组件的 AppImport 结构清晰,代码整洁,接口少(props只涉及公共的 store、history 等 )。再处理下StepN(子组件根据实际内容处理,这里略过),整个 AppImport 代码不超过150行,相比不划分组件,代码随便超过1000+行,划分优化后思路清晰,可维护性高。

最终代码:

import React, { Component, Fragment } from 'react';
import { observer } from 'mobx-react';
import { withRouter } from 'react-router-dom';
import { injectIntl, FormattedMessage } from 'react-intl';
import { Steps } from 'choerodon-ui';
import { Content, Header, Page, stores } from 'choerodon-front-boot';
import '../../../main.scss';
import './AppImport.scss';
import { Step0, Step1, Step2, Step3 } from './steps/index';

const { AppState } = stores;
const Step = Steps.Step;

@observer
class AppImport extends Component {
constructor() {
super(...arguments);
this.state = {
data: {},
current: 0,
};
}

next = (values) => {
// 点击下一步处理函数,略
};

prev = () => {
// 点击上一步处理函数,略
};

cancel = () => {
// 点击取消处理函数,略
};

importApp = () => {
// 点击导入,数据处理,略
};

render() {
const { current, data } = this.state;
// const ...

const steps = [{
key: 'step0',
title: <FormattedMessage id="app.import.step1" />,
content: <Step0 onNext={this.next} onCancel={this.cancel} store={AppStore} values={data} />,
}, {
key: 'step1',
title: <FormattedMessage id="app.import.step2" />,
content: <Step1 onNext={this.next} onPrevious={this.prev} onCancel={this.cancel} store={AppStore} values={data} />,
}, {
key: 'step2',
title: <FormattedMessage id="app.import.step3" />,
content: <Step2 onNext={this.next} onPrevious={this.prev} onCancel={this.cancel} store={AppStore} values={data} />,
}, {
key: 'step3',
title: <FormattedMessage id="app.import.step4" />,
content: <Step3 onImport={this.importApp} onPrevious={this.prev} onCancel={this.cancel} store={AppStore} values={data} />,
}];

return (
<Page>
<Header title:'xxx' backPath='xxxxxx' />
<Content code="app.import" values={{ name }}>
<div className="c7n-app-import-wrap">
<Steps current={current} className="steps-line">
{steps.map(item => <Step key={item.key} title:{item.title} />)}
</Steps>
<div className="steps-content">{steps[current].content}</div>
</div>
</Content>
</Page>
);
}
}

export default withRouter(injectIntl(AppImport));

过程中会接触到一些最佳实践和技巧:

  1. 避免 renderXXXX 函数
  2. 给回调函数类型的 props 加统一前缀(onNext、onXXX 或 handleXXX 规范,可读性好)
  3. 使用 propTypes 来定义组件的 props
  4. 尽量每个组件都有自己专属的源代码文件(StepN)
  5. 用解构赋值(destructuring assignment)的方法获取参数 props 的每个属性值
  6. 利用属性初始化(property initializer)来定义 state 和成员函数

组件设计模式

不同的业务情境下使用合适的设计模式能大大提高开发效率和可维护性。了解以上内容后能更好的理解和选择设计模式。

常用的设计模式有:

  1. 容器组件和展示组件(Container and Presentational Components);
  2. 高阶组件;
  3. render props 模式;
  4. 提供者模式(Provider Pattern);
  5. 组合组件。

网上介绍这些模式的文章有很多,每个模式都可以长篇详解。但是,模式就是特定于一种问题场景的解决办法。

模式(Pattern) = 问题场景(Context) + 解决办法(Solution)

明确使用场景才能正确发挥模式的功能。所以,简单介绍一下各模式实际应用于什么场景较好。

容器组件和展示组件

React最简单也是最常用的一种组件模式就是“容器组件和展示组件”。其本质就是把一个功能分配到两个组件中,形成父子关系,外层的父组件负责管理数据状态,内层的子组件只负责展示,让一个模块都专注于一个功能,这样更利于代码的维护。

上文步骤条的实例就是把获取和管理数据这件事和界面渲染这件事分开。做法就是,把获取和管理数据的逻辑放在父组件,也就是容器组件;把渲染界面的逻辑放在子组件,也就是展示组件。有关数据处理的变动就只需要对容器组件进行修改,例如修改数据状态管理方式,完全不影响展示组件。

高阶组件

高阶组件适用场景于“不要重复自己”(DRY,Don't Repeat Yourself)编码原则,某些功能是多个组件通用的,在每个组件都重复实现逻辑,浪费、可维护行低。第一想法是共用逻辑提取为一个 React 组件,但是共用逻辑单独无法使用,不足以抽象成组件,仅仅是对其他组件的功能加强。当然,高阶组件并不是 React 中唯一的重用组件逻辑的方式,下文的 render props 模式也可处理。

例如,很多网站应用,有些模块都需要在用户已经登录的情况下才显示。比如,对于一个电商类网站,“退出登录”按钮、“购物车”这些模块,就只有用户登录之后才显示,对应这些模块的 React 组件如果连“只有在登录时才显示”的功能都重复实现,那就浪费了。

render props 模式

所谓 render props,指的是让 React 组件的 props 支持函数这种模式。因为作为 props 传入的函数往往被用来渲染一部分界面,所以这种模式被称为 render props。适用场景和高阶组件差不多,但是与其还是有一些差别:

  1. render props 模式的应用,是一个 React 组件,而高阶组件,虽然名为“组件”,其实只是一个产生 React 组件的函数
  2. 高阶组件可链式调用,因为实质是函数
  3. render props 相对于高阶组件还有一个显著优势,就是对于新增的 props 更加灵活

所以以上对比,当需要重用 React 组件的逻辑时,建议首先看这个功能是否可以抽象为一个简单的组件;如果行不通的话,考虑是否可以应用 render props 模式;再不行的话,才考虑应用高阶组件模式。当然,没有绝对的使用顺序,实际场景为准。

提供者模式

在 React 中,props 是组件之间通讯的主要手段,但是,有一种场景单纯靠 props 来通讯是不恰当的,那就是两个组件之间间隔着多层其他组件。避免 props 逐级传递,即是提供者模式的适用场景。实现方式也分老Context API和新Context API。新版本的 Context API 才是未来,在 React v17 中,可能就会删除对老版 Context API 的支持,所以,现在大家都应该使用第二种实现方式。新版API详解

典型用例就是实现“样式主题”(Theme),多语言支持等。

组合组件

组合组件模式要解决的是这样一类问题:父组件想要传递一些信息给子组件,但是,如果用 props 传递又显得十分麻烦。利用 Context?当然还有其他解决方案,就是组合组件模式。

应用组合组件场景的往往是共享组件库,把一些常用的功能封装在组件里,让应用层直接用就行。在 antd 和 bootstrap 这样的共享库中,都使用了组合组件这种模式。将复杂度都封装起来了,从使用者角度,连 props 都看不见。实例扩展

总 结

对前端来说,前端不是不用设计模式,而是已经把设计模式融入到了开发的基础当中。Choerodon猪齿鱼平台前端真实的业务场景往往需要应用多个设计模式,界面也会包含多个大小不一的组件。开发设计时,符合程序设计的原则:「高内聚,低耦合」即可。本文只是简单总结,提供一些思路和简单的应用场景给开发者,真正的熟练把握和应用还得多实践开发使用,多对自己欠缺的知识点去深挖学习和思考,不断进步。

参考/引用资料:

关于猪齿鱼

Choerodon 猪齿鱼是一个全场景效能平台,基于 Kubernetes 的容器编排和管理能力,整合 DevOps 工具链、微服务和移动应用框架,来帮助企业实现敏捷化的应用交付和自动化的运营管理的平台,同时提供 IoT、支付、数据、智能洞察、企业应用市场等业务组件,致力帮助企业聚焦于业务,加速数字化转型。

大家也可以通过以下社区途径了解猪齿鱼的最新动态、产品特性,以及参与社区贡献: