[前端框架] React - 思考練習


本篇續接初階篇之後的實作思考練習,透過步驟一步步從原型視覺稿到實體呈現。本篇根據官方手冊逐步跟隨完成動作並根據自己的邏輯思考完成。

首先需求如下:

  1. JSON API 獲得的 DEMO 如下:
    [
    {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"}
    ];
  2. 透過與設計師溝通後希望呈現的畫面如下:
    • 顯示商品並規劃分類區域
    • 動態輸入過濾輸入商品名稱
    • 可篩選庫存商品

拆解成 Component

第一步先將視覺搞評估分解出有那些 React 組件做成 UI。如果設計師已提供的 PhotoShop 圖層名稱也可以拿來當作組件名稱。思考如何分解時根據 Single-responsibility principle(SRP) 單一職責原則將一個組件只負責一件事就好。這裡可分析出使用到 5 組 Componet 以及層級架構如下:

  • FilterableProductTable(橘): 包含整個範例
    • SearchBar(藍): 使用者輸入
    • ProductTable(綠): 資料集(包含總標題)
      • ProductCategoryRow(青): 分類標題
      • ProductRow(紅): 產品列

ProductTable 的簡易需求之總標題,在此範例教學將被歸類在此組件內。是否根據 SRP 原則另外獨立成一個 Componet 隨人而異,如果這個總標題的功能結構龐大複雜,就適合另外獨立一個名為 ProductTableHeader 的組件。

規劃靜態畫面

開始建立靜態且無互動性的 render 資料模型。注意以下事項:

  1. 先建立出 HTML 示意版型,在將版型打散到各組件去。
  2. 盡可能地重複利用小型組件,並透過 props 來傳遞資料。
  3. 不要使用 state,state 保留給互動使用。
  4. 組件編寫此例建議順序為由上至下(簡單應用),如果是大型專案開發則可考慮從下至上從零件開始設計並隨時測試組件。
  5. 資料從最上層開始傳遞進去,如有需要在分配給子組件透過 props 來傳遞。
<div id="container">
<form>
<input type="text" placeholder="Search...">
<p><label><input type="checkbox"> Only show products in stock</label></p>
</form>
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
<tr>
<th colspan="2">Sporting Goods</th>
</tr>
<tr>
<td>Football</td>
<td>$49.99</td>
</tr>
<tr>
<td>Baseball</td>
<td>$9.99</td>
</tr>
<tr>
<td><span style="color: red;">Basketball</span></td>
<td>$29.99</td>
</tr>
<tr>
<th colspan="2">Electronics</th>
</tr>
<tr>
<td>iPod Touch</td>
<td>$99.99</td>
</tr>
<tr>
<td><span style="color: red;">iPhone 5</span></td>
<td>$399.99</td>
</tr>
<tr>
<td>Nexus 7</td>
<td>$199.99</td>
</tr>
</tbody>
</table>
</div>

FilterableProductTable

整個範例的組件,並資料從這裡開始傳入。而根據前面分析這裡會包 SearchBar 與 ProductTable 組件。

import { Component } from 'react';
import ReactDOM from 'react-dom';

class FilterableProductTable extends Component {
render() {
return (
<>
<SearchBar />
<ProductTable products={this.props.products} />
</>
)
};
}
//這裡用空標籤是因為不想多一個 div

//模擬資料 API 來源
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('root')
);

使用者輸入的組件。這裡先不做互動性,只單純提供 render 靜態介面部分。

class SearchBar extends Component {
render() {
return (
<form>
<input type="text" placeholder="Search..." />
<p><label><input type="checkbox" />Only show products in stock</label></p>
</form>
);
}
}
//因為 JSX 要求結尾標籤,所以 input 要補上/結尾符號

ProductTable

資料集(包含總標題)的組件。

  • 這裡會除了輸出總標題(透過 return 版型時寫死即可)。
  • 需要優先判斷資料的分類。慶幸資料的順序根據分類已排列完畢,因此透過 foreach 當下順便檢查每筆資料的分類變化當下時額外進行 ProductCategoryRow 的組件輸出即可。
  • 注意由於最終會直接 JSX 渲染標籤陣列,因此陣列內的標籤需要綁訂 key 提供 React 做識別。key 可以從資料差異性尋找替代,或者先前提到的第三方 Nano ID 套件來產生隨機碼。
  • <tr>細節從下層組件來負責,讓這層的標籤陣列單純批次執行即可。
  • 下層的 ProductCategoryRow 組件需要告知分類名,ProductRow 需要告知單筆商品資料,皆透過 props 傳遞。
class ProductTable extends Component {
render() {
const rows = [];
let checkCategory = null;
this.props.products.forEach(row => {
if (checkCategory !== row.category) rows.push(<ProductCategoryRow key={row.category} category={row.category} />);
rows.push(<ProductRow key={row.name} product={row} />);
checkCategory = row.category;
});

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

ProductCategoryRow

分類標題的組件,工作只要將接收的 props 渲染成版型出來。

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

ProductRow

產品列的組件。額外需判別 stocked 值來給予行內樣式紅字效果。由於 JSX 對於 style 屬性有反應,不建議以字串來指定,而改物件寫法來賦予 style 值。

class ProductRow extends Component {
render() {
const data = this.props.product;
// const name = data.stocked ? data.name : <span style='color:red'>{data.name}</span>;
//獲得警告,style 需要指定一個變數(且使用物件來寫),{{}} 代表插入一個{}物件的{}表達式

const name = data.stocked ? data.name : <span style={{ color: 'red' }}>{data.name}</span>;
return (
<tr>
<td>{name}</td>
<td>{data.price}</td>
</tr>
);
}
}

完整代碼

結果如下

import { Component } from 'react';
import ReactDOM from 'react-dom';

class ProductRow extends Component {
render() {
const data = this.props.product;
// const name = data.stocked ? data.name : <span style='color:red'>{data.name}</span>;
//獲得警告,style 需要指定一個變數(且使用物件來寫),{{}} 代表插入一個{}物件的{}表達式

const name = data.stocked ? data.name : <span style={{ color: 'red' }}>{data.name}</span>;
return (
<tr>
<td>{name}</td>
<td>{data.price}</td>
</tr>
);
}
}

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

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

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

class SearchBar extends Component {
render() {
return (
<form>
<input type="text" placeholder="Search..." />
<p><label><input type="checkbox" />Only show products in stock</label></p>
</form>
);
}
}
//因為 JSX 要求結尾標籤,所以 input 要補上/結尾符號

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

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 互動 UI

將互動性規劃進入,根據 Don’t repeat yourself 避免重複代碼原則 (DRY) 試著分析出變動量找出最少的呈現方式。而 state 本身作為互動用,有以下特性可以協助判斷是否為 state:

  • 如果資料是透過 props 傳遞來的,那可能不是 state
  • 如果資料不會變換,那可能不是 state
  • 這個資料可否經過 Componet 而透過其他 props 或 state 推演計算而得到的值,如果可以絕對不會是 state

根據目前的代碼的資料處部分有以下位置,且根據可分析出 state 互動的規劃為 2 處:

  • 原本的產品列表 (經過 props 產生的,不是 state)
  • 使用者輸入的搜尋關鍵字(隨用戶輸入不同而變換,且無法被推演計算出來)
  • checkbox 的值(隨用戶輸入不同而變換,且無法被推演計算出來)
  • 篩選過後的產品列表(透過產品列表、關鍵字、checkobx 所推演出來的,不是 state)

因此我們需要兩個 state 值,分別是關鍵字串與 checkbox 的狀態值。關鍵字串的 state 命名為 filterText 以及 checkbox 狀態 state 命名為 inStockOnly。跟此 2 組 state 互動有關的為:

  • ProductTable:根據此 filterText 以及 inStockOnly 的結果來變換 render 出來的資料。
  • SearchBar:從 input 觸發 change 事件將 state.filterText 修改;從 checkbox 觸發 change 事件將 state.inStockOnly 修改。

插入 state

先處理 state 的後續動作,開始插入步驟如下:

  1. 由於這兩個 component 都需要用到這 2 組 state,因此皆提升到父組件 FilterableProductTable 內來創立初始化。
  2. 透過標籤屬性將 state 傳遞給 ProductTable 與 SearchBar 作為 props。
  3. SearchBar 組件部分,將 props(父組件的 state) 指定給自己的表單 2 組 value。
  4. ProductTable 組件部分,調整 foreach 作業流程,使得產生的標籤陣列有所變化:
    • 利用 string 原生函式 indexOf 來查找關鍵字串是否存在於 product.name 內,不成立就跳脫 return。
    • 當 inStockOnly 值為 true 時,product.stocked 若為 false 則跳脫 return。
  5. 以上完成後,嘗試在 state 初始給予測試用的固定值,觀察變化是否成功。
import { Component } from 'react';
import ReactDOM from 'react-dom';

class ProductRow extends Component {
render() {
const data = this.props.product;
// const name = data.stocked ? data.name : <span style='color:red'>{data.name}</span>;
//獲得警告,style 需要指定一個變數(且使用物件來寫),{{}} 代表插入一個{}物件的{}表達式

const name = data.stocked ? data.name : <span style={{ color: 'red' }}>{data.name}</span>;
return (
<tr>
<td>{name}</td>
<td>{data.price}</td>
</tr>
);
}
}

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

class ProductTable extends Component {
render() {
const rows = [];
let checkCategory = null;

const
filterText = this.props.filterText,
inStockOnly = this.props.inStockOnly;

this.props.products.forEach(row => {
//檢查不需輸出的可能
if (row.name.indexOf(filterText) === -1) return;
if (inStockOnly && !row.stocked) return;

if (checkCategory !== row.category) rows.push(<ProductCategoryRow key={row.category} category={row.category} />);
rows.push(<ProductRow key={row.name} product={row} />);
checkCategory = row.category;
});

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

class SearchBar extends Component {
render() {
return (
<form>
<input type="text" placeholder="Search..." value={this.props.filterText} />
<p><label><input type="checkbox" checked={this.props.inStockOnly} />Only show products in stock</label></p>
</form>
);
}
}
//因為 JSX 要求結尾標籤,所以 input 要補上/結尾符號

class FilterableProductTable extends Component {
state = {
filterText: 'ball',
inStockOnly: true
}
render() {
return (
<>
<SearchBar
filterText={this.state.filterText}
inStockOnly={this.state.inStockOnly}
/>
<ProductTable
filterText={this.state.filterText}
inStockOnly={this.state.inStockOnly}
products={this.props.products}
/>
</>
)
};
}

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 修改的能力往下傳遞,透過 porps 形成可反向的資料流動。這裡只有 SearchBar 會需要修改父組件的 state。

  1. 在父組件 FilterableProductTable 規劃兩個 event 事件 dofilterTextChange 與 doinStockOnlyChange,其中參數 temp 屆時會由下層 SearchBar 提供回來。
  2. 同上,透過 props 傳遞事件函式給下層 SearchBar。
  3. 在子組件 SearchBar 也規劃兩個同名 event 事件 dofilterTextChange 與 doinStockOnlyChange,其中參數 e 為用戶操作下的 event 參數,將 event 參數執行在 props 傳來的父函式上,形成向上層資料傳遞。
  4. 同上,記得 render 的 JSX 綁定此兩 event 事件。使得用戶能觸發 change 事件。
  5. 最後,測試一下無誤後,修改原本 FilterableProductTable 為了測試用的 state 應有初始值。
import { Component } from 'react';
import ReactDOM from 'react-dom';

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

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

class ProductTable extends Component {
render() {
const rows = [];
let checkCategory = null;

const
filterText = this.props.filterText,
inStockOnly = this.props.inStockOnly;

this.props.products.forEach(row => {
if (row.name.indexOf(filterText) === -1) return;
if (inStockOnly && !row.stocked) return;

if (checkCategory !== row.category) rows.push(<ProductCategoryRow key={row.category} category={row.category} />);
rows.push(<ProductRow key={row.name} product={row} />);
checkCategory = row.category;
});

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

class SearchBar extends Component {
dofilterTextChange = (e) => {
this.props.dofilterTextChange(e.target.value);
};
doinStockOnlyChange = (e) => {
this.props.doinStockOnlyChange(e.target.checked);
}

render() {
return (
<form>
<input type="text" placeholder="Search..."
value={this.props.filterText}
onChange={this.dofilterTextChange}
/>
<p><label>
<input type="checkbox"
checked={this.props.inStockOnly}
onChange={this.doinStockOnlyChange}
/>
Only show products in stock
</label></p>
</form>
);
}
}

class FilterableProductTable extends Component {
state = {
filterText: '',
inStockOnly: false
}
dofilterTextChange = (temp) => {
this.setState({
filterText: temp
});
}
doinStockOnlyChange = (temp) => {
this.setState({
inStockOnly: temp
})
}
render() {
return (
<>
<SearchBar
filterText={this.state.filterText}
inStockOnly={this.state.inStockOnly}
dofilterTextChange={this.dofilterTextChange}
doinStockOnlyChange={this.doinStockOnlyChange}
/>
<ProductTable
filterText={this.state.filterText}
inStockOnly={this.state.inStockOnly}
products={this.props.products}
/>
</>
)
};
}

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