6
本文是对React官网《Thinking in React》一文的翻译,通过这篇文章,React团队向开发者们介绍了应该如果去构思一个web应用,为今后使用React进行web app的构建,打下基础。 以下是正文。

在我们团队看来,React是使用JavaScript构建大型、快速的Web apps的首选方式。它已经在Facebook和Instagram项目中,表现出了非常好的可扩展性。

能够按照构建的方式来思考web app的实现,是React众多优点之一。在这篇文章中,我们将引导你进行使用React构建可搜索产品数据表的思考过程。

从设计稿开始

想象一下,我们已经有了一个JSON API和来自设计师的设计稿。如下图所示:

Mockup

JSON API返回的数据如下所示:

[
  {category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
  {category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
  {category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
  {category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
  {category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
  {category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];

第一步:将UI分解为组件并分析层级结构

我们要做的第一件事就是给设计稿中的每个组件(和子组件)画框,并给它们起名字。如果你正在和一个设计师合作,他可能已经帮你完成了这一步。他的Photoshop图层名称可能最终会成为你的React组件名称!

但我们怎么知道自己的组件应该是什么?只需要使用一些通用的技巧来决定是否应该创建一个新的函数或对象。其中一个技巧叫做:单一责任原则。就是说,在理想情况下,一个组件应该只用来完成一件事。若非如此,则应该考虑将其分解成更小的子组件。

我们经常会向用户展示JSON数据模型,那么你应该会发现,如果模型构建正确,那么你的UI(以及组件结构)应该能够很好地映射数据模型。这是因为UI和数据模型倾向于遵循相同的信息架构,这意味着将UI分解为组件的工作通常是微不足道的。现在我们把它分解成映射数据模型的组件如下:

Component diagram

现在我们的示例应用中有了五个组件,而且我们将每个组件代表的数据用斜体表示如下:

  1. FilterableProductTable (橘黄色):包含整个示例的组件
  2. SearchBar (蓝色):接收所有的用户输入
  3. ProductTable (绿色):根据用户输入显示和过滤数据集
  4. ProductCategoryRow (绿宝石色):显示分类头
  5. ProductRow (红色):每行显示一条商品数据

细心的你会发现,在ProductTable中,表头(包含名称价格标签)不是一个组件。这是一个偏好的问题,有两个方面的论点。在这个例子中,我们将其作为ProductTable组件的一部分,因为它是ProductTable负责渲染的数据集的一部分。但是,如果这个头部变得很复杂(比如我们要支持排序),那么将其设置为ProductTableHeader这样的组件肯定会更好一些。

现在我们已经确定了设计稿中的组件,下一步我们要给这些组件安排层次结构。这其实很容易:出现在一个组件中的组件应该在层次结构中显示为一个子组件:

  • FilterableProductTable

    • SearchBar
    • ProductTable

      • ProductCategoryRow
      • ProductRow

第二步:用React构建一个静态版本

class ProductCategoryRow extends React.Component {
  render() {
    const category = this.props.category;
    return (
      <tr>
        <th colSpan="2">
          {category}
        </th>
      </tr>
    );
  }
}

class ProductRow extends React.Component {
  render() {
    const product = this.props.product;
    const name = product.stocked ?
      product.name :
      <span style={{color: 'red'}}>
        {product.name}
      </span>;

    return (
      <tr>
        <td>{name}</td>
        <td>{product.price}</td>
      </tr>
    );
  }
}

class ProductTable extends React.Component {
  render() {
    const rows = [];
    let lastCategory = null;
    
    this.props.products.forEach((product) => {
      if (product.category !== lastCategory) {
        rows.push(
          <ProductCategoryRow
            category={product.category}
            key={product.category} />
        );
      }
      rows.push(
        <ProductRow
          product={product}
          key={product.name} />
      );
      lastCategory = product.category;
    });

    return (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Price</th>
          </tr>
        </thead>
        <tbody>{rows}</tbody>
      </table>
    );
  }
}

class SearchBar extends React.Component {
  render() {
    return (
      <form>
        <input type="text" placeholder="Search..." />
        <p>
          <input type="checkbox" />
          {' '}
          Only show products in stock
        </p>
      </form>
    );
  }
}

class FilterableProductTable extends React.Component {
  render() {
    return (
      <div>
        <SearchBar />
        <ProductTable products={this.props.products} />
      </div>
    );
  }
}


const PRODUCTS = [
  {category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
  {category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
  {category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
  {category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
  {category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
  {category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];
 
ReactDOM.render(
  <FilterableProductTable products={PRODUCTS} />,
  document.getElementById('container')
);

现在我们已经有了组件层次结构,接下来可以实现应用程序了。最初的方案是构建一个使用数据模型渲染UI但不具有交互性的版本。最好将静态版本和添加交互性进行解耦,因为构建一个静态的版本需要大量的输入却不需要思考,而增加交互性需要大量的思考而不需要很多输入。我们一会儿会知道为什么。

要构建渲染数据模型的静态版本,需要构建可复用其他组件并使用props传递数据的组件。props是一种将数据从父组件传递给子组件的方式。如果你熟悉state的概念,请不要使用state来构建这个静态版本。state只为实现交互性而保留,即随时间变化的数据。由于这是应用程序的静态版本,所以暂时不需要它。

你的构建过程可以自上而下或自下而上。也就是说,你可以从构建层次较高的组件(即FilterableProductTable)开始或较低的组件(ProductRow开始)。在简单的例子中,自上而下通常比较容易,而在大型项目中,自下而上更容易而且更易于编写测试用例

在这一步的最后,你会有一个可重用组件的库来渲染你的数据模型。这些组件只会有render()方法,因为这是你的应用程序的静态版本。层次结构顶部的组件(FilterableProductTable)将把你的数据模型作为一个prop。如果你对基础数据模型进行更改并再次调用ReactDOM.render(),则UI将会更新。这就很容易看到用户界面是如何更新以及在哪里进行更改了,因为没有任何复杂的事情发生。 React的单向数据流(也称为单向绑定)使所有的事务更加模块化也更加快速。

第三步:确定UI状态的最小(但完整)表示形式

为了使你的UI具有交互性,需要能够触发对基础数据模型的更改。 React使用state让这一切变得简单。要正确构建应用程序,首先需要考虑应用程序需要的最小可变状态集。这里的关键是:不要重复自己。找出应用程序需要的状态的绝对最小表示,并计算需要的其他所有内容。例如,如果你正在创建一个TODO列表,只需要保存一个TODO项目的数组;不要为计数保留一个单独的状态变量。相反,当你要渲染TODO数量时,只需取TODO项目数组的长度即可。

考虑我们示例应用程序中的所有数据。我们有:

  • 产品的原始列表
  • 用户输入的搜索文本
  • 复选框的值
  • 过滤的产品列表

我们来看看每一个是哪一个state。这里有关于每条数据的三个问题:

  1. 是通过props从父组件传入的吗?如果是,那可能不是state。
  2. 它是否保持不变?如果是,那可能不是state。
  3. 你能基于组件中的任何其他state或props来计算它吗?如果是,那不是state。

原来的产品清单是作为props传入的,所以这不是state。搜索文本和复选框似乎是state,因为它们随着时间而改变,不能从任何东西计算。最后,产品的过滤列表不是state,因为它可以通过将产品的原始列表与复选框的搜索文本和值组合来计算得到。

所以最后,我们的states是:

  • 用户输入的搜索文本
  • 复选框的值

第四步: 确定你的state需要放置在什么地方

class ProductCategoryRow extends React.Component {
  render() {
    const category = this.props.category;
    return (
      <tr>
        <th colSpan="2">
          {category}
        </th>
      </tr>
    );
  }
}

class ProductRow extends React.Component {
  render() {
    const product = this.props.product;
    const name = product.stocked ?
      product.name :
      <span style={{color: 'red'}}>
        {product.name}
      </span>;

    return (
      <tr>
        <td>{name}</td>
        <td>{product.price}</td>
      </tr>
    );
  }
}

class ProductTable extends React.Component {
  render() {
    const filterText = this.props.filterText;
    const inStockOnly = this.props.inStockOnly;

    const rows = [];
    let lastCategory = null;

    this.props.products.forEach((product) => {
      if (product.name.indexOf(filterText) === -1) {
        return;
      }
      if (inStockOnly && !product.stocked) {
        return;
      }
      if (product.category !== lastCategory) {
        rows.push(
          <ProductCategoryRow
            category={product.category}
            key={product.category} />
        );
      }
      rows.push(
        <ProductRow
          product={product}
          key={product.name}
        />
      );
      lastCategory = product.category;
    });

    return (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Price</th>
          </tr>
        </thead>
        <tbody>{rows}</tbody>
      </table>
    );
  }
}

class SearchBar extends React.Component {
  render() {
    const filterText = this.props.filterText;
    const inStockOnly = this.props.inStockOnly;

    return (
      <form>
        <input
          type="text"
          placeholder="Search..."
          value={filterText} />
        <p>
          <input
            type="checkbox"
            checked={inStockOnly} />
          {' '}
          Only show products in stock
        </p>
      </form>
    );
  }
}

class FilterableProductTable extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      filterText: '',
      inStockOnly: false
    };
  }

  render() {
    return (
      <div>
        <SearchBar
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
        />
        <ProductTable
          products={this.props.products}
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
        />
      </div>
    );
  }
}


const PRODUCTS = [
  {category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
  {category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
  {category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
  {category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
  {category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
  {category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];

ReactDOM.render(
  <FilterableProductTable products={PRODUCTS} />,
  document.getElementById('container')
);

现在我们已经确定了最小的一组应用程序state。接下来,我们需要确定哪个组件会改变或拥有这个state。

请记住:数据在React的组件层次结构中是单向流动的。它可能不清楚哪个组件应该拥有什么状态。这通常是新手理解的最具挑战性的部分,所以请按照以下步骤解决:

对于你的应用程序中的每一个state:

  • 确定基于该state渲染某些内容的每个组件。
  • 找到一个共同的拥有者组件(一个在所有需要该state的层次结构组件之上的组件)。
  • 无论是共同所有者,还是高层次的其他组成部分,都应该拥有这个state。
  • 如果你无法找到一个有意义的组件,那么只好创建一个新的组件来保存state,并将其添加到公共所有者组件上方的层次结构中的某个位置。

让我们来看看我们的应用程序的这个策略:

  • ProductTable需要根据状态过滤产品列表,而SearchBar需要显示搜索文本和检查状态。
  • 通用所有者组件是FilterableProductTable
  • 从概念上讲,过滤器文本和选中的值存在于FilterableProductTable中是有意义的

酷,所以我们已经决定,我们的state存活在FilterableProductTable中。首先,将一个实例属性this.state = {filterText:'',inStockOnly:false}添加到FilterableProductTable的构造函数中,以反映应用程序的初始状态。然后,将filterTextinStockOnly作为prop传递给ProductTableSearchBar。最后,使用这些props来筛选ProductTable中的行,并在SearchBar中设置表单域的值。

你可以看到你的应用程序的行为了:设置filterText为“ball”,并刷新你的应用程序。你将看到数据表已正确更新。

第五步:添加反向数据流

class ProductCategoryRow extends React.Component {
  render() {
    const category = this.props.category;
    return (
      <tr>
        <th colSpan="2">
          {category}
        </th>
      </tr>
    );
  }
}

class ProductRow extends React.Component {
  render() {
    const product = this.props.product;
    const name = product.stocked ?
      product.name :
      <span style={{color: 'red'}}>
        {product.name}
      </span>;

    return (
      <tr>
        <td>{name}</td>
        <td>{product.price}</td>
      </tr>
    );
  }
}

class ProductTable extends React.Component {
  render() {
    const filterText = this.props.filterText;
    const inStockOnly = this.props.inStockOnly;

    const rows = [];
    let lastCategory = null;

    this.props.products.forEach((product) => {
      if (product.name.indexOf(filterText) === -1) {
        return;
      }
      if (inStockOnly && !product.stocked) {
        return;
      }
      if (product.category !== lastCategory) {
        rows.push(
          <ProductCategoryRow
            category={product.category}
            key={product.category} />
        );
      }
      rows.push(
        <ProductRow
          product={product}
          key={product.name}
        />
      );
      lastCategory = product.category;
    });

    return (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Price</th>
          </tr>
        </thead>
        <tbody>{rows}</tbody>
      </table>
    );
  }
}

class SearchBar extends React.Component {
  constructor(props) {
    super(props);
    this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
    this.handleInStockChange = this.handleInStockChange.bind(this);
  }
  
  handleFilterTextChange(e) {
    this.props.onFilterTextChange(e.target.value);
  }
  
  handleInStockChange(e) {
    this.props.onInStockChange(e.target.checked);
  }
  
  render() {
    return (
      <form>
        <input
          type="text"
          placeholder="Search..."
          value={this.props.filterText}
          onChange={this.handleFilterTextChange}
        />
        <p>
          <input
            type="checkbox"
            checked={this.props.inStockOnly}
            onChange={this.handleInStockChange}
          />
          {' '}
          Only show products in stock
        </p>
      </form>
    );
  }
}

class FilterableProductTable extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      filterText: '',
      inStockOnly: false
    };
    
    this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
    this.handleInStockChange = this.handleInStockChange.bind(this);
  }

  handleFilterTextChange(filterText) {
    this.setState({
      filterText: filterText
    });
  }
  
  handleInStockChange(inStockOnly) {
    this.setState({
      inStockOnly: inStockOnly
    })
  }

  render() {
    return (
      <div>
        <SearchBar
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
          onFilterTextChange={this.handleFilterTextChange}
          onInStockChange={this.handleInStockChange}
        />
        <ProductTable
          products={this.props.products}
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
        />
      </div>
    );
  }
}


const PRODUCTS = [
  {category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
  {category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
  {category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
  {category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
  {category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
  {category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];

ReactDOM.render(
  <FilterableProductTable products={PRODUCTS} />,
  document.getElementById('container')
);

到目前为止,我们已经构建了一个应用程序,可以根据props和state正确地呈现在层次结构中。现在是时候以另一种方式支持数据流:深层次的表单组件需要更新FilterableProductTable中的状态。

React使这个数据流清晰易懂,以便理解你的程序是如何工作的,但是它需要比传统的双向数据绑定更多的输入。

如果你尝试在当前版本的示例中键入或选中该框,则会看到React忽略了你的输入。这是故意的,因为我们已经将输入的值prop设置为始终等于从FilterableProductTable传入的state。

让我们想想我们想要发生的事情。我们希望确保每当用户更改表单时,我们都会更新状态以反映用户的输入。由于组件应该只更新自己的state,只要state需要更新时,FilterableProductTable就会传递回调到SearchBar。我们可以使用输入上的onChange事件来通知它。 FilterableProductTable传递的回调将调用setState(),并且应用程序将被更新。

虽然这听起来很复杂,但实际上只是几行代码。你的数据如何在整个应用程序中流动变得非常明确。

就是这样

希望这篇文章可以让你了解如何用React来构建组件和应用程序。虽然它可能比以前多一些代码,但请记住,代码的读远远超过它的写,并且读取这个模块化的显式代码非常容易。当你开始构建大型组件库时,你将会体会到这种明确性和模块性,并且通过代码重用,你的代码行将开始缩小。

备注

文中所有示例的HTML和CSS内容如下:

<div id="container">
    <!-- This element's contents will be replaced with your component. -->
</div>
body {
  padding: 5px
}

司想君
1.2k 声望77 粉丝

要战胜自我,首先要战胜自己的习惯思维