React程式設計思想

司想君發表於2018-02-08

本文是對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
}
複製程式碼

相關文章