组件驱动开发
组件驱动开发
Component Driven Development is a way of building user interfaces (UIs) by starting with their smallest parts: the components. Emphasis on components is a theme in UI development that has been gaining momentum since the introduction of modern UI libraries like React.
CDD denotes a set of tools (such as React Storybook) and a way of developing applications to really take advantage of this change in emphasis. Some advantages of CDD include:
- The ability to parallelize work as different people work on different components.
- The ability to use a “Visual TDD” approach to allow building UIs in a more rigorous fashion.
- Increased communication opportunities between designers and other product people and the developers building the components.
- Reuse of components between applications and features.
I’ll talk more about why I think CDD is great in future posts, but hopefully some of these advantages will become clear as we develop our todo list app.
组件代码风格
本小节我们关注如何写出漂亮的组件,你或许可以认为萝卜青菜各有所爱,但是代码本身是应当保证其可读性,特别是在一个团队中,你的代码是注定要被其他人阅读的。计算机是不会在意这些的,不管你朝它们扔过去什么,它们都会老老实实的解释,但是你的队友们可不会这样,他们会把丑陋的代码扔回到你的脸上。一般来说,漂亮的组件应该具备以下特征:
- 即使没有任何注释的情况下也易于理解
- 比乱麻般的代码有更好的性能表现
- 更易于进行
Bug 追溯 - 简洁明了,一句顶一万句
在讨论语法的语法细节之前,我们应该遵循如下的基本原则:
React.createElement
方法。
组件规范
尽可能地使用
// bad
const Listing = React.createClass({
render() {
return <div />;
}
});
// good
class Listing extends React.Component {
render() {
return <div />;
}
}
// bad
const reservationCard = require("./ReservationCard");
// good
const ReservationCard = require("./ReservationCard");
// bad
const ReservationItem = <ReservationCard />;
// good
const reservationItem = <ReservationCard />;
### Props
// bad
<Foo
UserName="hello"
phone_number={12345678}
/>
// good
<Foo
userName="hello"
phoneNumber={12345678}
/>
import React, { Component, PropTypes } from "react";
const propTypes = {
id: PropTypes.number.isRequired,
url: PropTypes.string.isRequired,
text: PropTypes.string
};
const defaultProps = {
text: "Hello World"
};
export default class Link extends Component {
static methodsAreOk() {
return true;
}
render() {
return (
<a href={this.props.url} data-id={this.props.id}>
{this.props.text}
</a>
);
}
}
Link.propTypes = propTypes;
Link.defaultProps = defaultProps;
// bad
export default React.createClass({
displayName: 'ReservationCard',
// stuff goes here
});
// good
export default class ReservationCard extends React.Component {
}
合理使用对象结构与属性扩展
大的组件往往受困于this.props
过长的窘境,典型的如下所示
render() {
return (
<ProductPrice
hidePriceFulfillmentDisplay=
{this.props.hidePriceFulfillmentDisplay}
primaryOffer={this.props.primaryOffer}
productType={this.props.productType}
productPageUrl={this.props.productPageUrl}
inventory={this.props.inventory}
submapType={this.props.submapType}
ppu={this.props.ppu}
isLoggedIn={this.props.isLoggedIn}
gridView={this.props.isGridView}
/>
);
}
这么多的
render() {
const {
hidePriceFulfillmentDisplay,
primaryOffer,
productType,
productPageUrl,
inventory,
submapType,
ppu,
isLoggedIn,
gridView
} = this.props;
return (
<ProductPrice
hidePriceFulfillmentDisplay={hidePriceFulfillmentDisplay}
primaryOffer={primaryOffer}
productType={productType}
productPageUrl={productPageUrl}
inventory={inventory}
submapType={submapType}
ppu={ppu}
isLoggedIn={isLoggedIn}
gridView={isGridView}
/>
);
}
暂时不考虑
render() {
const props = this.props;
return <ProductPrice {...props} />
}
JSX 规范
// bad
<Foo superLongParam="bar"
anotherSuperLongParam="baz" />
// good
<Foo
superLongParam="bar"
anotherSuperLongParam="baz"
/>
// if props fit in one line then keep it on the same line
<Foo bar="bar" />
// children get indented normally
<Foo
superLongParam="bar"
anotherSuperLongParam="baz"
>
<Spazz />
</Foo>
### Quotes
对于
// bad
<Foo bar='bar' />
// good
<Foo bar="bar" />
// bad
<Foo style={{ left: "20px" }} />
// good
<Foo style={{ left: '20px' }} />
// bad
<Foo/>
// very bad
<Foo />
// bad
<Foo
/>
// good
<Foo />
/// bad
render() {
return <MyComponent className="long body" foo="bar">
<MyChild />
</MyComponent>;
}
// good
render() {
return (
<MyComponent className="long body" foo="bar">
<MyChild />
</MyComponent>
);
}
// good, when single line
render() {
const body = <div>hello</div>;
return <MyComponent>{body}</MyComponent>;
}
方法规范
// bad
React.createClass({
_onClickSubmit() {
// do stuff
}
// other stuff
});
// good
class extends React.Component {
onClickSubmit() {
// do stuff
}
// other stuff
});
1. constructor 2. optional static methods 3. getChildContext 4. componentWillMount 5. componentDidMount 6. componentWillReceiveProps 7. shouldComponentUpdate 8. componentWillUpdate 9. componentDidUpdate 10. componentWillUnmount 11. clickHandlers or eventHandlers like onClickSubmit() or onChangeDescription() 12. getter methods for render like getSelectReason() or getFooterContent() 13. Optional render methods like renderNavigation() or renderProfilePicture() 14. render
- React.createClass
1. displayName 2. propTypes 3. contextTypes 4. childContextTypes 5. mixins 6. statics 7. defaultProps 8. getDefaultProps 9. getInitialState 10. getChildContext 11. componentWillMount 12. componentDidMount 13. componentWillReceiveProps 14. shouldComponentUpdate 15. componentWillUpdate 16. componentDidUpdate 17. componentWillUnmount 18. clickHandlers or eventHandlers like onClickSubmit() or onChangeDescription() 19. getter methods for render like getSelectReason() or getFooterContent() 20. Optional render methods like renderNavigation() or renderProfilePicture() 21. render
使用箭头函数减少冗余代码
箭头函数是this
指针,还能让我们不用声明过多的function
关键字,譬如我觉得非常适用
const mapStateToProps = ({isLoading}) => {
return ({
loading: isLoading,
});
};
需要注意的是,如果你返回的是
const mapStateToProps = ({isLoading}) => ({
loading: isLoading
});
# Communication
Input
对于
class Title extends React.Component {
render() {
return <h1>{ this.props.text }</h1>;
}
};
Title.propTypes = {
text: React.PropTypes.string
};
Title.defaultProps = {
text: 'Hello world'
};
// App.jsx
class App extends React.Component {
render() {
return <Title text='Hello React' />;
}
};
text
是Text
组件自己的输入域,父组件App
在使用子组件Title
时候应该提供text
属性值。除了标准的属性名之外,我们还会用到如下两个设置
propTypes: 用于定义Props 的类型,这有助于追踪运行时误设置的Prop 值。defaultProps: 定义Props 的默认值,这个在开发时很有帮助
props.children
可以允许我们使用子组件
render() {
return (
<h1>
{ this.props.text }
{ this.props.children }
</h1>
);
}
};
class App extends React.Component {
render() {
return (
<Title text='Hello React'>
<span>community</span>
</Title>
);
}
};
注意,如果我们不主动在Title
组件的render
函数中设置{this.props.children}
,那么span
标签是不会被渲染出来的。除了context
,整个context
对象,它可以被树中挂载的每个组件所访问到,关于此部分更多的内容请参考依赖注入这一章节。
Output
组件最明显的输出就是渲染后的
render() {
return (
<h1>
<a onClick={ this.props.logoClicked }>
<img src='path/to/logo.png' />
</a>
</h1>
);
}
};
class App extends React.Component {
render() {
return <Title logoClicked={ this.logoClicked } />;
}
logoClicked() {
console.log('logo clicked');
}
};
在App
组件中我们向Title
组件传入了可以从Title
调用的回调函数,在logoClicked
函数中我们可以设置或者修改需要传回父组件的数据。需要注意的是,this.props.children[0].state
或者类似的方法。正确的从子组件中获取数据的方法应该是在
Composition
App
Header
以及Navigation
。将这三个组件依次嵌套组合,可以得到以下的代码
<Header>
<Navigation> ... </Navigation>
</Header>
</App>
而在
import Header from './Header.jsx';
export default class App extends React.Component {
render() {
return <Header />;
}
}
// Header.jsx
import Navigation from './Navigation.jsx';
export default class Header extends React.Component {
render() {
return <header><Navigation /></header>;
}
}
// Navigation.jsx
export default class Navigation extends React.Component {
render() {
return (<nav> ... </nav>);
}
}
不过这种方式却可能存在以下的问题
- 我们将
App
当做各个组件间的连接线,也是整个应用的入口,因此在App
中进行各个独立组件的组合是个不错的方法。不过Header
元素中可能包含像图标、搜索栏或者Slogan 这样的元素。而如果我们需要另一个不包含Navigation
功能的Header
组件时,像上面这种直接将Navigation
组件硬编码进入Header
的方式就会难于修改。 - 这种硬编码的方式会难以测试,如果我们在
Header
中加入一些自定义的业务逻辑代码,那么在测试的时候当我们要创建Header
实例时,因为其依赖于其他组件而导致了这种依赖层次过深( 这里不包含Shallow Rendering这种仅渲染父组件而不渲染嵌套的子组件方式) 。
使用React 的children
API
this.props.children
来允许父组件访问其子组件,这种方式有助于保证我们的Header
独立并且不需要与其他组件解耦合。
export default class App extends React.Component {
render() {
return (
<Header>
<Navigation />
</Header>
);
}
}
// Header.jsx
export default class Header extends React.Component {
render() {
return <header>{ this.props.children }</header>;
}
};
这种方式也有助于测试,我们可以选择输入空白的div
元素,从而将要测试的目标元素隔离开来而专注于我们需要测试的部分。
将子组件以属性方式传入
class App extends React.Component {
render() {
var title = <h1>Hello there!</h1>;
return (
<Header title={ title }>
<Navigation />
</Header>
);
}
};
// Header.jsx
export default class Header extends React.Component {
render() {
return (
<header>
{ this.props.title }
<hr />
{ this.props.children }
</header>
);
}
};
这种方式在我们需要对传入的待组合组件进行一些修正时非常适用。
Higher-order components
class Enhance extends React.Component {
render() {
return (
<Component
{...this.state}
{...this.props}
/>
)
}
};
export default enhanceComponent;
通常情况下我们会构建一个工厂函数,接收原始的组件然后返回一个所谓的增强或者包裹后的版本,譬如
class App extends React.Component {
render() {
return React.createElement(enhanceComponent(OriginalComponent));
}
};
一般来说,高阶组件的首要工作就是渲染原始的组件,我们经常也会将
var enhanceComponent = (Component) =>
class Enhance extends React.Component {
render() {
return (
<Component
{...this.state}
{...this.props}
title={ config.appTitle }
/>
)
}
};
这里对于configuration
的细节实现会被隐藏到高阶组件中,原始组件只需要了解从title
变量然后渲染到界面上。原始组件并不会关心变量存于何地,从何而来,这种模式最大的优势在于我们能够以独立的模式对该组件进行测试,并且可以非常方便地对该组件进行
Dependency injection
我们写的大部分组件与模块都会包含一些依赖,合适的依赖管理有助于创建良好可维护的项目结构。而所谓的依赖注入技术正是解决这个问题的常用技巧,无论是在
export default function Title(props) {
return <h1>{ props.title }</h1>;
}
// Header.jsx
import Title from './Title.jsx';
export default function Header() {
return (
<header>
<Title />
</header>
);
}
// App.jsx
import Header from './Header.jsx';
class App extends React.Component {
constructor(props) {
super(props);
this.state = { title: 'React in patterns' };
}
render() {
return <Header />;
}
};
title
这个变量的值是在App
组件中被定义好的,我们需要将其传入到Title
组件中。最直接的方法就是将其从App
组件传入到Header
组件,然后再由Header
组件传入到Title
组件中。这种方法在这里描述的简单的仅有三个组件的应用中还是非常清晰可维护的,不过随着项目功能与复杂度的增加,这种层次化的传值方式会导致很多的组件要去考虑它们并不需要的属性。在上文所讲的title
变量
var title = 'React in patterns';
var enhanceComponent = (Component) =>
class Enhance extends React.Component {
render() {
return (
<Component
{...this.state}
{...this.props}
title={ title }
/>
)
}
};
export default enhanceComponent;
// Header.jsx
import enhance from './enhance.jsx';
import Title from './Title.jsx';
var EnhancedTitle = enhance(Title);
export default function Header() {
return (
<header>
<EnhancedTitle />
</header>
);
}
在上文这种title
变量被包含在了一个隐藏的中间层中,我们将其作为Title
变量中并且得到一个新的组件。这种方式思想是不错,不过还是只解决了部分问题。现在我们可以不去显式地将title
变量传递到Title
组件中即可以达到同样的enhance.jsx
效果。
context
的概念,context
是贯穿于整个
var context = { title: 'React in patterns' };
class App extends React.Component {
getChildContext() {
return context;
}
...
};
App.childContextTypes = {
title: React.PropTypes.string
};
// a place where we need data
class Inject extends React.Component {
render() {
var title = this.context.title;
...
}
}
Inject.contextTypes = {
title: React.PropTypes.string
};
注意,我们要使用childContextTypes
与contextTypes
指明其构成。如果在context
对象中未指明这些那么context
会被设置为空,这可能会添加些额外的代码。因此我们最好不要将context
当做一个简单的
export default {
data: {},
get(key) {
return this.data[key];
},
register(key, value) {
this.data[key] = value;
}
}
这样,我们的App
组件会被改造成这样子
dependencies.register('title', 'React in patterns');
class App extends React.Component {
getChildContext() {
return dependencies;
}
render() {
return <Header />;
}
};
App.childContextTypes = {
data: React.PropTypes.object,
get: React.PropTypes.func,
register: React.PropTypes.func
};
而在Title
组件中,我们需要进行如下设置
export default class Title extends React.Component {
render() {
return <h1>{ this.context.get('title') }</h1>
}
}
Title.contextTypes = {
data: React.PropTypes.object,
get: React.PropTypes.func,
register: React.PropTypes.func
};
当然我们不希望在每次要使用contextTypes
的时候都需要显式地声明一下,我们可以将这些声明细节包含在一个高阶组件中。
import wire from './wire';
function Title(props) {
return <h1>{ props.title }</h1>;
}
export default wire(Title, ['title'], function resolve(title) {
return { title };
});
这里的wire
函数的第一个参数是register
函数。最后一个参数则是所谓的映射函数,它接收存储在context
中的某个原始值然后返回context
中存储的值与Title
组件中需要的值都是title
变量,因此我们直接返回即可。不过在真实的应用中可能是一个数据集合、配置等等。
class Inject extends React.Component {
render() {
var resolved = dependencies.map(this.context.get.bind(this.context));
var props = mapper(...resolved);
return React.createElement(Component, props);
}
}
Inject.contextTypes = {
data: React.PropTypes.object,
get: React.PropTypes.func,
register: React.PropTypes.func
};
return Inject;
};
这里的context
的高阶组件,而mapper
就是用于接收context
中的数据并将其转化为组件所需要的context
,我觉得了解这种方式的底层原理还是很有意义的。譬如现在流行的Redux
,其核心的connect
函数与Provider
组件都是基于context
。
One direction data flow
单向数据流是Switcher
组件,当我们点击该按钮时会触发某个flag
变量的改变
constructor(props) {
super(props);
this.state = { flag: false };
this._onButtonClick = e => this.setState({ flag: !this.state.flag });
}
render() {
return (
<button onClick={ this._onButtonClick }>
{ this.state.flag ? 'lights on' : 'lights off' }
</button>
);
}
};
// ... and we render it
class App extends React.Component {
render() {
return <Switcher />;
}
};
此时我们将所有的数据放置到组件内,换言之,Switcher
是唯一的包含我们flag
变量的地方,我们来尝试下将这些数据托管于专门的
_flag: false,
set: function(value) {
this._flag = value;
},
get: function() {
return this._flag;
}
};
class Switcher extends React.Component {
constructor(props) {
super(props);
this.state = { flag: false };
this._onButtonClick = e => {
this.setState({ flag: !this.state.flag }, () => {
this.props.onChange(this.state.flag);
});
}
}
render() {
return (
<button onClick={ this._onButtonClick }>
{ this.state.flag ? 'lights on' : 'lights off' }
</button>
);
}
};
class App extends React.Component {
render() {
return <Switcher onChange={ Store.set.bind(Store) } />;
}
};
这里的Store
对象是一个简单的单例对象,可以帮助我们设置与获取_flag
属性值。而通过将getter
函数传递到组件内,可以允许我们在Store
外部修改这些变量,此时我们的应用工作流大概是这样的
|
Switcher -------> Store
假设我们已经将flag
值保存到某个后端服务中,我们需要为该组件设置一个合适的初始状态。此时就会存在一个问题在于同一份数据保存在了两个地方,对于Store
分别保存了各自独立的关于flag
的数据状态,我们等于在Store
与Switcher
之间建立了双向的数据流Store ---> Switcher
与Switcher ---> Store
<Switcher
value={ Store.get() }
onChange={ Store.set.bind(Store) } />
// ... in Switcher component
constructor(props) {
super(props);
this.state = { flag: this.props.value };
...
此时我们的数据流向变成了
|
Switcher <-------> Store
^ |
| |
| |
| v
Service communicating
with our backend
在这种双向数据流下,如果我们在外部改变了Store
中的状态之后,我们需要将改变之后的最新值更新到Switcher
中,这样也在无形之间增加了应用的复杂度。而单向数据流则是解决了这个问题,它强制在全局只保留一个状态存储,通常是存放在
_handlers: [],
_flag: '',
onChange: function(handler) {
this._handlers.push(handler);
},
set: function(value) {
this._flag = value;
this._handlers.forEach(handler => handler())
},
get: function() {
return this._flag;
}
};
然后我们在App
组件中设置了钩子函数,这样每次Store
改变其值的时候我们都会强制重新渲染
constructor(props) {
super(props);
Store.onChange(this.forceUpdate.bind(this));
}
render() {
return (
<div>
<Switcher
value={ Store.get() }
onChange={ Store.set.bind(Store) } />
</div>
);
}
};
注意,这里使用的forceUpdate
并不是一个推荐的用法,我们通常会使用forceUpdate
只是用于演示说明。在基于上述的改造,我们就不需要在组件中继续保留内部状态
constructor(props) {
super(props);
this._onButtonClick = e => {
this.props.onChange(!this.props.value);
}
}
render() {
return (
<button onClick={ this._onButtonClick }>
{ this.props.value ? 'lights on' : 'lights off' }
</button>
);
}
};
这种模式的优势在于会将我们的组件改造为简单的Store
中数据的呈现,此时才是真正无状态的
with our backend
^
|
v
Store <-----
||
v|
Switcher ---->
^
|
|
User input
在这种单向数据流中我们不再需要同步系统中的多个部分,这种单向数据流的概念并不仅仅适用于基于