组件驱动开发

组件驱动开发

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 组件。 -  尽可能地使用 JSX 语法。 -  除非不用 JSX 语法创建一个应用,否则不要使用React.createElement方法。

组件规范

### Class 与 React.createClass 方法

尽可能地使用 ES6 中的类的语法,除非有特殊的对于 Mixin 的需求。

// bad
const Listing = React.createClass({
  render() {
    return <div />;
  }
});

// good
class Listing extends React.Component {
  render() {
    return <div />;
  }
}

###  组件命名

-  扩展名:使用.jsx 作为 React 组件的扩展名。 -  文件名:使用帕斯卡命名法命名文件,譬如 ReservationCard.jsx。 -  引用命名:使用帕斯卡命名法命名组件和 camelCase 命名实例。

// bad
const reservationCard = require("./ReservationCard");

// good
const ReservationCard = require("./ReservationCard");

// bad
const ReservationItem = <ReservationCard />;

// good
const reservationItem = <ReservationCard />;

### Props

-  对于 Props 的命名使用 camelCase。

// bad
<Foo
  UserName="hello"
  phone_number={12345678}
/>

// good
<Foo
  userName="hello"
  phoneNumber={12345678}
/>

-  将 Props 或者 State 的声明写在类外。

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;

### Declaration(声明)

-  不要使用 displayName 来命名组件,而使用引用。

// 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}
  />
  );
}

这么多的 Props 估计看着都头疼,如果我们要将这些 Props 继续传入下一层,大概就要变成下面这个样子了:

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}
  />
  );
}

暂时不考虑 unKnown Props,我们可以使用解构赋值来实现这个功能:

render() {
  const props = this.props;
  return <ProductPrice {...props} />
}

JSX 规范

### Alignment(对齐)

-  跟随如下的 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

对于 JSX 的属性用双引号表示,对于其他属性,用单引号表示。

// bad
<Foo bar='bar' />

// good
<Foo bar="bar" />

// bad
<Foo style={{ left: "20px" }} />

// good
<Foo style={{ left: '20px' }} />

### Spacing(空格)

-  在自闭合的标签中仅使用单空格。

// bad
<Foo/>

// very bad
<Foo                 />

// bad
<Foo
 />

// good
<Foo />

###  多段

-  当 JSX 包含多行代码时,将它们包含在小括号中。

/// 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>;
}

方法规范

### Naming(方法命名)

-  对于一个 React 组件的内部方法,不要使用下划线作为前缀。

// bad
React.createClass({
  _onClickSubmit() {
    // do stuff
  }

  // other stuff
});

// good
class extends React.Component {
  onClickSubmit() {
    // do stuff
  }

  // other stuff
});

### Ordering(顺序)

- React.Component 子类

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

使用箭头函数减少冗余代码

箭头函数是 ES6 引入的新特性之一,其不仅可以帮我们避免手动绑定this指针,还能让我们不用声明过多的function关键字,譬如我觉得非常适用 Arrow Function 的地方就是 Redux 的 mapStateToProps 函数:

const mapStateToProps = ({isLoading}) => {
  return ({
  loading: isLoading,
  });
};

需要注意的是,如果你返回的是 Object,你需要包裹在大括号内:

const mapStateToProps = ({isLoading}) => ({
  loading: isLoading
});

# Communication React 组件一个很大的特性在于其拥有自己完整的生命周期,因此我们可以将 React 组件视作可自运行的小型系统,它拥有自己的内部状态、输入与输出。

Input

对于 React 组件而言,其输入的来源就是 Props,我们会用如下方式向某个 React 组件传入数据:

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' />;
  }
};

textText组件自己的输入域,父组件App在使用子组件Title时候应该提供text属性值。除了标准的属性名之外,我们还会用到如下两个设置:

  • propTypes:用于定义 Props 的类型,这有助于追踪运行时误设置的 Prop 值。
  • defaultProps:定义 Props 的默认值,这个在开发时很有帮助

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标签是不会被渲染出来的。除了 Props 之外,另一个隐性的组件的输入即是context,整个 React 组件树会拥有一个context对象,它可以被树中挂载的每个组件所访问到,关于此部分更多的内容请参考依赖注入这一章节。

Output

组件最明显的输出就是渲染后的 HTML 文本,即是 React 组件渲染结果的可视化展示。当然,部分包含了逻辑的组件也可能发送或者触发某些 Action 或者 Event。

  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函数中我们可以设置或者修改需要传回父组件的数据。需要注意的是,React 并没有提供可以访问子组件状态的 API,换言之,我们不能使用this.props.children[0].state或者类似的方法。正确的从子组件中获取数据的方法应该是在 Props 中传入回调函数,而这种隔离也有助于我们定义更加清晰的 API 并且促进了所谓单向数据流。

Composition

React 最大的特性之一即是其强大的组件的可组合性,实际上除了 React 之外,笔者并不知道还有哪个框架能够提供如此简单易用的方式来创建与组合各式各样的组件。本章我们会一起讨论些常用的组合技巧,我们以一个简单的例子来进行讲解。假设在我们的应用中有一个页首栏目,并且其中放置了导航栏。我们创建了三个独立的 React 组件:App,Header以及Navigation。将这三个组件依次嵌套组合,可以得到以下的代码:

  <Header>
    <Navigation> ... </Navigation>
  </Header>
</App>

而在 JSX 中组合这些组件的方式就是在需要的时候引用它们:

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 的childrenAPI

React 为我们提供了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元素,从而将要测试的目标元素隔离开来而专注于我们需要测试的部分。

将子组件以属性方式传入

React 组件可以接受 Props 作为输入,我们也可以选择将需要封装的组件以 Props 方式传入:

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

Higher-Order Components 模式看上去非常类似于装饰器模式,它会用于包裹某个组件然后为其添加一些新的功能。这里展示一个简单的用于构造 Higher-Order Component 的函数:

  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));
  }
};

一般来说,高阶组件的首要工作就是渲染原始的组件,我们经常也会将 Props 与 State 传递进去,将这两个属性传递进去会有助于我们建立一个数据代理。HOC 模式允许我们控制组件的输入,即将需要传入的数据以 Props 传递进去。譬如我们需要为原始组件添加一些配置:

var enhanceComponent = (Component) =>
  class Enhance extends React.Component {
    render() {
      return (
        <Component
          {...this.state}
          {...this.props}
          title={ config.appTitle }
        />
      )
    }
  };

这里对于configuration的细节实现会被隐藏到高阶组件中,原始组件只需要了解从 Props 中获取到title变量然后渲染到界面上。原始组件并不会关心变量存于何地,从何而来,这种模式最大的优势在于我们能够以独立的模式对该组件进行测试,并且可以非常方便地对该组件进行 Mocking。在 HOC 模式下我们的原始组件会变成这样子:

Dependency injection

我们写的大部分组件与模块都会包含一些依赖,合适的依赖管理有助于创建良好可维护的项目结构。而所谓的依赖注入技术正是解决这个问题的常用技巧,无论是在 Java 还是其他应用程序中,依赖注入都受到了广泛的使用。而 React 中对于依赖注入的需要也是显而易见的,让我们假设有如下的应用树结构:

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组件中。这种方法在这里描述的简单的仅有三个组件的应用中还是非常清晰可维护的,不过随着项目功能与复杂度的增加,这种层次化的传值方式会导致很多的组件要去考虑它们并不需要的属性。在上文所讲的 HOC 模式中我们已经使用了数据注入的方式,这里我们使用同样的技术来注入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>
  );
}

在上文这种 HOC 模式中,title变量被包含在了一个隐藏的中间层中,我们将其作为 Props 值传入到原始的Title变量中并且得到一个新的组件。这种方式思想是不错,不过还是只解决了部分问题。现在我们可以不去显式地将title变量传递到Title组件中即可以达到同样的enhance.jsx效果。 React 为我们提供了context的概念,context是贯穿于整个 React 组件树允许每个组件访问的对象。有点像所谓的 Event Bus,一个简单的例子如下所示:

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
};

注意,我们要使用 context 对象必须要通过childContextTypescontextTypes指明其构成。如果在context对象中未指明这些那么context会被设置为空,这可能会添加些额外的代码。因此我们最好不要将context当做一个简单的 object 对象而为其设置一些封装方法:

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函数的第一个参数是 React 组件对象,第二个参数是一系列需要注入的依赖值,注意,这些依赖值务必已经调用过register函数。最后一个参数则是所谓的映射函数,它接收存储在context中的某个原始值然后返回 React Props 中需要的值。因为在这个例子里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;
};

这里的 Inject 就是某个可以访问context的高阶组件,而mapper就是用于接收context中的数据并将其转化为组件所需要的 Props 的函数。实际上现在大部分的依赖注入的解决方案都是基于context,我觉得了解这种方式的底层原理还是很有意义的。譬如现在流行的Redux,其核心的connect函数与Provider组件都是基于context

One direction data flow

单向数据流是 React 中主要的数据驱动模式,其核心概念在于组件并不会修改它们接收到的数据,它们只是负责接收新的数据而后重新渲染到界面上或者发出某些 Action 以触发某些专门的业务代码来修改数据存储中的数据。我们先设置一个包含一个按钮的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变量的地方,我们来尝试下将这些数据托管于专门的 Store 中:

  _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值保存到某个后端服务中,我们需要为该组件设置一个合适的初始状态。此时就会存在一个问题在于同一份数据保存在了两个地方,对于 UI 与Store分别保存了各自独立的关于flag的数据状态,我们等于在StoreSwitcher之间建立了双向的数据流:Store ---> SwitcherSwitcher ---> 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中,这样也在无形之间增加了应用的复杂度。而单向数据流则是解决了这个问题,它强制在全局只保留一个状态存储,通常是存放在 Store 中。在单向数据流下,我们需要添加一些订阅 Store 中状态改变的响应函数:

  _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并不是一个推荐的用法,我们通常会使用 HOC 模式来进行重渲染,这里使用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中数据的呈现,此时才是真正无状态的 View。我们可以以完全声明式的方式来编写组件,而将应用中复杂的业务逻辑放置到单独的地方。此时我们应用程序的流图变成了:

with our backend
^
|
v
Store <-----
||
v|
Switcher ---->
^
|
|
User input

在这种单向数据流中我们不再需要同步系统中的多个部分,这种单向数据流的概念并不仅仅适用于基于 React 的应用。

上一页