Skip to main content

2 篇博文 含有标签「react

View All Tags

· 26 分钟阅读

React在前端界一直很流行,而且学起来也不是很难,只需要学会JSX、理解StateProps,然后就可以愉快的玩耍了,但想要成为React的专家你还需要对React有一些更深入的理解,希望本文对你有用。

这是Choerodon的一个前端页面

在复杂的前端项目中一个页面可能包含上百个状态,对React框架理解得更精细一些对前端优化很重要。曾经这个页面点击一条记录展示详情会卡顿数秒,而这仅仅是前端渲染造成的。

为了能够解决这些问题,开发者需要了解React组件从定义到在页面上呈现(然后更新)的整个过程。

React在编写组件时使用混合HTMLJavaScript的一种语法(称为JSX)。 但是,浏览器对JSX及其语法一无所知,浏览器只能理解纯JavaScript,因此必须将JSX转换为HTML。 这是一个div的JSX代码,它有一个类和一些内容:

<div className='cn'>
文本
</div>

在React中将这段jsx变成普通的js之后它就是一个带有许多参数的函数调用:

React.createElement(
'div',
{ className: 'cn' },
'文本'
);
React.createElement(
'div',
{ className: 'cn' },
['Content 1!', React.createElement('br'), 'Content 2!']
)
它的第一个参数是一个字符串,对应html中的标签名,第二个参数是它的所有属性所构成的对象,当然,它也有可能是个空对象,剩下的参数都是这个元素下的子元素,这里的文本也会被当作一个子元素,所以第三个参数是 `“文本”`

到这里你应该就能想象这个元素下有更多`children`的时候会发生什么。
```html
<div className='cn'>
文本1
<br />
文本2
</div>
React.createElement(
'div',
{ className: 'cn' },
'文本1', // 1st child
React.createElement('br'), // 2nd child
'文本1' // 3rd child
)

目前的函数有五个参数:元素的类型,全部属性的对象和三个子元素。 由于一个child也是React已知的HTML标签,因此它也将被解释成函数调用。

到目前为止,本文已经介绍了两种类型的child参数,一种是string纯文本,一种是调用其他的React.createElement函数。其实,其他值也可以作为参数,比如:

  • 基本类型 false,null,undefined和 true
  • 数组
  • React组件

使用数组是因为可以将子组件分组并作为一个参数传递:


当然,React的强大功能不是来自`HTML`规范中描述的标签,而是来自用户创建的组件,例如:

```js
function Table({ rows }) {
return (
<table>
{rows.map(row => (
<tr key={row.id}>
<td>{row.title}</td>
</tr>
))}
</table>
);
}

组件允许开发者将模板分解为可重用的块。在上面的“纯函数”组件的示例中,组件接受一个包含表行数据的对象数组,并返回React.createElement对table元素及其行作为子元素的单个调用 。

每当开发者将组件放入JSX布局中时它看上去是这样的:

<Table rows={rows} />

但从浏览器角度,它看到的是这样的:

React.createElement(Table, { rows: rows });

请注意,这次的第一个参数不是以string描述的HTML元素,而是组件的引用(即函数名)。第二个参数是传入该组件的props对象。

将组件放在页面上

现在,浏览器已经将所有JSX组件转换为纯JavaScript,现在浏览器获得了一堆函数调用,其参数是其他函数调用,还有其他函数调用......如何将它们转换为构成网页的DOM元素?

为此,开发者需要使用ReactDOM库及其render方法:

function Table({ rows }) { /* ... */ } // 组件定义

// 渲染一个组件
ReactDOM.render(
React.createElement(Table, { rows: rows }), // "创建" 一个 component
document.getElementById('#root') // 将它放入DOM中
);

ReactDOM.render被调用时,React.createElement最终也会被调用,它返回以下对象:

// 这个对象里还有很多其他的字段,但现在对开发者来说重要的是这些。
{
type: Table,
props: {
rows: rows
},
// ...
}

这些对象构成了React意义上的Virtual DOM

它们将在所有进一步渲染中相互比较,并最终转换为真正的DOM(与Virtual DOM对比)。

这是另一个例子:这次有一个div具有class属性和几个子节点:

React.createElement(
'div',
{ className: 'cn' },
'Content 1!',
'Content 2!',
);

变成:

{
type: 'div',
props: {
className: 'cn',
children: [
'Content 1!',
'Content 2!'
]
}
}

所有的传入的展开函数,也就是React.createElement除了第一第二个参数剩下的参数都会在props对象中的children属性中,不管传入的是什么函数,他们最终都会作为children传入props中。

而且,开发者可以直接在JSX代码中添加children属性,将子项直接放在children中,结果仍然是相同的:

<div className='cn' children={['Content 1!', 'Content 2!']} />

在Virtual DOM对象被建立出来之后ReactDOM.render会尝试按以下规则把它翻译成浏览器能够看得懂的DOM节点:

  • 如果Virtual DOM对象中的type属性是一个string类型的tag名称,创建一个tag,包含props里的全部属性。
  • 如果Virtual DOM对象中的type属性是一个函数或者class,调用它,它返回的可能还是一个Virtual DOM然后将结果继续递归调用此过程。
  • 如果props中有children属性,对children中的每个元素进行以上过程,并将返回的结果放到父DOM节点中。

最后,浏览器获得了以下HTML(对于上述table的例子):

<table>
<tr>
<td>Title</td>
</tr>
...
</table>

重建DOM

接下浏览器要“重建”一个DOM节点,如果浏览器要更新一个页面,显然,开发者并不希望替换页面中的全部元素,这就是React真正的魔法了。如何才能实现它?先从最简单的方法开始,重新调用这个节点的ReactDOM.render方法。

// 第二次调用
ReactDOM.render(
React.createElement(Table, { rows: rows }),
document.getElementById('#root')
);

这一次,上面的代码执行逻辑将与看到的代码不同。React不是从头开始创建所有DOM节点并将它们放在页面上,React将使用“diff”算法,以确定节点树的哪些部分必须更新,哪些部分可以保持不变。

那么它是怎样工作的?只有少数几个简单的情况,理解它们将对React程序的优化有很大帮助。请记住,接下来看到的对象是用作表示React Virtual DOM中节点的对象。

▌Case 1:type是一个字符串,type在调用之间保持不变,props也没有改变。

// before update
{ type: 'div', props: { className: 'cn' } }

// after update
{ type: 'div', props: { className: 'cn' } }

这是最简单的情况:DOM保持不变。

▌Case 2:type仍然是相同的字符串,props是不同的。

// before update:
{ type: 'div', props: { className: 'cn' } }

// after update:
{ type: 'div', props: { className: 'cnn' } }

由于type仍然代表一个HTML元素,React知道如何通过标准的DOM API调用更改其属性,而无需从DOM树中删除节点。

▌Case 3:type已更改为不同的组件String或从String组件更改为组件。

// before update:
{ type: 'div', props: { className: 'cn' } }

// after update:
{ type: 'span', props: { className: 'cn' } }

由于React现在看到类型不同,它甚至不会尝试更新DOM节点:旧元素将与其所有子节点一起被删除(unmount)。因此,在DOM树上替换完全不同的元素的代价会非常之高。幸运的是,这在实际情况中很少发生。

重要的是要记住React使用===(三等)来比较type值,因此它们必须是同一个类或相同函数的相同实例。

下一个场景更有趣,因为这是开发者最常使用React的方式。

▌Case 4:type是一个组件。

// before update:
{ type: Table, props: { rows: rows } }

// after update:
{ type: Table, props: { rows: rows } }

你可能会说,“这好像没有任何变化”,但这是不对的。

如果type是对函数或类的引用(即常规React组件),并且启动了树diff比较过程,那么React将始终尝试查看组件内部的所有child以确保render的返回值没有更改。即在树下比较每个组件 - 是的,复杂的渲染也可能变得昂贵!

组件中的children

除了上面描述的四种常见场景之外,当元素有多个子元素时,开发者还需要考虑React的行为。假设有这样一个元素:

// ...
props: {
children: [
{ type: 'div' },
{ type: 'span' },
{ type: 'br' }
]
},
// ...

开发者开发者想将它重新渲染成这样(spandiv交换了位置):

// ...
props: {
children: [
{ type: 'span' },
{ type: 'div' },
{ type: 'br' }
]
},
// ...

那么会发生什么?

当React看到里面的任何数组类型的props.children,它会开始将它中的元素与之前看到的数组中的元素按顺序进行比较:index 0将与index 0,index 1与index 1进行比较,对于每对子元素,React将应用上述规则集进行比较更新。在以上的例子中,它看到div变成一个span这是一个情景3中的情况。但这有一个问题:假设开发者想要从1000行表中删除第一行。React必须“更新”剩余的999个孩子,因为如果与先前的逐个索引表示相比,他们的内容现在将不相等。

幸运的是,React有一种内置的方法来解决这个问题。如果元素具有key属性,则元素将通过key而不是索引进行比较。只要key是唯一的,React就会移动元素而不将它们从DOM树中移除,然后将它们放回(React中称为挂载/卸载的过程)。

// ...
props: {
children: [ // 现在react就是根据key,而不是索引来比较了
{ type: 'div', key: 'div' },
{ type: 'span', key: 'span' },
{ type: 'br', key: 'bt' }
]
},
// ...

当状态改变时

到目前为止,本文只触及了props,React哲学的一部分,但忽略了state。这是一个简单的“有状态”组件:

class App extends Component {
state = { counter: 0 }

increment = () => this.setState({
counter: this.state.counter + 1,
})

render = () => (<button onClick={this.increment}>
{'Counter: ' + this.state.counter}
</button>)
}

现在,上述例子中的state对象有一个counter属性。单击按钮会增加其值并更改按钮文本。但是当用户点击时,DOM会发生什么?它的哪一部分将被重新计算和更新?

调用this.setState也会导致重新渲染,但不会导致整个页面重渲染,而只会导致组件本身及其子项。父母和兄弟姐妹都可以幸免于难。

修复问题

本文准备了一个DEMO,这是修复问题前的样子。你可以在这里查看其源代码。不过在此之前,你还需要安装React Developer Tools

打开demo要看的第一件事是哪些元素以及何时导致Virtual DOM更新。导航到浏览器的Dev Tools中的React面板,点击设置然后选择“Highlight Updates”复选框:

现在尝试在表中添加一行。如你所见,页面上的每个元素周围都会出现边框。这意味着每次添加行时,React都会计算并比较整个Virtual DOM树。现在尝试按一行内的计数器按钮。你将看到Virtual DOM如何更新 (state仅相关元素及其子元素更新)。

React DevTools暗示了问题可能出现的地方,但没有告诉开发者任何细节:特别是有问题的更新是指元素“diff”之后有不同,还是组件被unmount/mount了。要了解更多信息,开发者需要使用React的内置分析器(请注意,它不能在生产模式下工作)。

转到Chrome DevTools中的“Performance”标签。点击record按钮,然后点击表格。添加一些行,更改一些计数器,然后点击“Stop”按钮。稍等一会儿之后开发者会看到:

在结果输出中,开发者需要关注“Timing”。缩放时间轴,直到看到“React Tree Reconciliation”组及其子项。这些都是组件的名称,旁边有[update][mount]。可以看到有一个TableRow被mount了,其他所有的TableRow都在update,这并不是开发者想要的。

大多数性能问题都由[update][mount]引起

一个组件(以及组件下的所有东西)由于某种原因在每次更新时重新挂载,开发者不想让它发生(重新挂载很慢),或者在大型分支上执行代价过大的重绘,即使组件似乎没有发生任何改变。

修复mount/unmount

现在,当开发者了解React如何决定更新Virtual DOM并知道幕后发生的事情时,终于准备好解决问题了!修复性能问题首先要解决 mount/unmount。

如果开发者将任何元素/组件的多个子元素在内部表示为数组,那么程序可以获得非常明显的速度提升。

考虑一下:

<div>
<Message />
<Table />
<Footer />
</div>

在虚拟DOM中,将表示为:

// ...
props: {
children: [
{ type: Message },
{ type: Table },
{ type: Footer }
]
}
// ...

一个简单的Message组件(是一个div带有一些文本,像是猪齿鱼的顶部通知)和一个很长的Table,比方说1000多行。它们都是div元素的child,因此它们被放置在父节点的props.children之下,并且它们没有key。React甚至不会通过控制台警告来提醒开发者分配key,因为子节点React.createElement作为参数列表而不是数组传递给父节点。

现在,用户已经关闭了顶部通知,所以Message从树中删除。TableFooter是剩下的child。

// ...
props: {
children: [
{ type: Table },
{ type: Footer }
]
}
// ...

React如何看待它?它将它视为一系列改变了type的child:children[0]的type本来是Message,但现在他是Table。因为它们都是对函数(和不同函数)的引用,它会卸载整个Table并再次安装它,渲染它的所有子代:1000多行!

因此,你可以添加唯一键(但在这种特殊情况下使用key不是最佳选择)或者采用更智能的trick:使用 && 的布尔短路运算,这是JavaScript和许多其他现代语言的一个特性。像这样:

<div>
{isShowMessage && <Message />}
<Table />
<Footer />
</div>

即使Message被关闭了(不再显示),props.children父母div仍将拥有三个元素,children[0]具有一个值false(布尔类型)。还记得true/false, null甚至undefined都是Virtual DOM对象type属性的允许值吗?浏览器最终得到类似这样的东西:

// ...
props: {
children: [
false, // isShowMessage && <Message /> 短路成了false
{ type: Table },
{ type: Footer }
]
}
// ...

所以,不管Message是否被显示,索引都不会改变,Table仍然会和Table比较,但仅仅比较Virtual DOM通常比删除DOM节点并从中创建它们要快得多。

现在来看看更高级的东西。开发者喜欢HOC。高阶组件是一个函数,它将一个组件作为一个参数,添加一些行为,并返回一个不同的组件(函数):

function withName(SomeComponent) {
return function(props) {
return <SomeComponent {...props} name={name} />;
}
}

开发者在父render方法中创建了一个HOC 。当React需要重新渲染树时,React 的Virtual DOM将如下所示:

// On first render:
{
type: ComponentWithName,
props: {},
}

// On second render:
{
type: ComponentWithName, // Same name, but different instance
props: {},
}

现在,React只会在ComponentWithName上运行一个diff算法,但是这次同名引用了一个不同的实例,三等于比较失败,必须进行完全重新挂载。注意它也会导致状态丢失,幸运的是,它很容易修复:只要返回的实例都是同一个就好了:

// 单例
const ComponentWithName = withName(Component);

class App extends React.Component() {
render() {
return <ComponentWithName />;
}
}

修复update

现在浏览器已经确保不会重新装载东西了,除非必要。但是,对位于DOM树根目录附近的组件所做的任何更改都将导致其所有子项的进行对比重绘。结构复杂,价格昂贵且经常可以避免。

如果有办法告诉React不要查看某个分支,那将是很好的,因为它没有任何变化。

这种方式存在,它涉及一个叫shouldComponentUpdate的组件生命周期函数。React会在每次调用组件之前调用此方法,并接收propsstate的新值。然后开发者可以自由地比较新值和旧值之间的区别,并决定是否应该更新组件(返回truefalse)。如果函数返回false,React将不会重新渲染有问题的组件,也不会查看其子组件。

通常比较两组propsstate一个简单的浅层比较就足够了:如果顶层属性的值相同,浏览器就不必更新了。浅比较不是JavaScript的一个特性,但开发者很多方法来自己实现它,为了不重复造轮子,也可以使用别人写好的方法

在引入浅层比较的npm包后,开发者可以编写如下代码:

class TableRow extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
const { props, state } = this;
return !shallowequal(props, nextProps)
&& !shallowequal(state, nextState);
}
render() { /* ... */ }
}

但是你甚至不必自己编写代码,因为React在一个名为React.PureComponent的类中内置了这个功能,它类似于React.Component,只是shouldComponentUpdate已经为你实现了浅层props/state比较。

或许你会有这样的想法,能替换ComponentPureComponent就去替换。但开发者如果错误地使用PureComponent同样会有重新渲染的问题存在,需要考虑下面三种情况:

<Table
// map每次都会返回一个新的数组实例,所以每次比较都是不同的
rows={rows.map(/* ... */)}
// 每一次传入的对象都是新的对象,引用是不同的。
style={ { color: 'red' } }
// 箭头函数也一样,每次都是不同的引用。
onUpdate:{() => { /* ... */ }}
/>

上面的代码片段演示了三种最常见的反模式,请尽量避免它们!

正确地使用PureComponent,你可以在这里看到所有的TableRow都被“纯化”后渲染的效果。

但是,如果你迫不及待想要全部使用纯函数组件,这样是不对的。比较两组propsstate不是免费的,对于大多数基本组件来说甚至都不值得:运行shallowCompare比diff算法需要更多时间。

可以使用此经验法则:纯组件适用于复杂的表单和表格,但它们通常会使按钮或图标等简单元素变慢。

现在,你已经熟悉了React的渲染模式,接下来就开始前端优化之旅吧。

关于猪齿鱼

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

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

· 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、支付、数据、智能洞察、企业应用市场等业务组件,致力帮助企业聚焦于业务,加速数字化转型。

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