學習 React.js:用 React.js 和 Flux 創建一個簡單的購物車

Creating A Simple Shopping Cart with React.js and Flux

Ken Wheeler (@ken_wheeler)

簡介

歡迎來到學習 React 的第四章這也是最後一章!到現在,我們已經學習了怎樣利用 React 的 API 來創建狀態型組件,如何應用它們,以及如何運用臉書的 Flux 架構來工作的

今天我們將把所有的這一切放到一塊,來創建一個簡單的購物車應用。在現在的電商網站上,產品的詳細頁面相互依賴,而 React 有助於簡化並有效的組織它們。

如果你還沒準備好,我強烈建議你看看這個學習系列前面的三篇文章:

  • 學習 React.js : 概念和起步
  • 學習 React.js:用 Node 和 React.js 創建一個實時的 Twitter 流
  • 學習 React.js:瞭解 Flux,React.js 的架構

ReactJS 0.12

在寫這系列文章的時候,React 的 0.12 版發布了,而且還有不少變化。這篇教程將會用 0.12 的語法來寫。這些改變包括以下這些:

  • 用 JSX 語法的時候不再需要 /** @jsx React.DOM */ 頭聲明了
  • renderComponent 改成 render
  • renderComponentToString 改成 renderToString

你可以在官方的博客的更新日志了解更多的改变。

開始

我們搭建一個應用的第一步是首先確定我們應該做什麼。我們需要這樣:

  • 顯示有幾個選項的產品
  • 價格隨著不同選擇改變
  • 追加物品到購物車
  • 從購物車移除物品
  • 顯示在購物車中商品個數
  • 顯示在購物車中商品總價
  • 根據選項顯示每種商品的小結
  • 當沒有庫存的時候,把"放到購物車"改成"售罄”
  • 點擊"查看購物車"之後跳轉購物車頁面

下面是完成圖:

https://cask.scotch.io/2014/10/loWHKFz.png

這個應用將用純客戶端開發,原因是我不打算寫服務端。我們將會用模擬 API 以及模擬數據,這樣,我們可以集中在組件上面。下面來看看我們的目錄結構:

目錄結構

<!-- lang: js -->
css/
---- app.css
img/
---- scotch-beer.png
js/
---- actions/
-------- FluxCartActions.js     // Our app's action creators
---- components/
-------- FluxCart.react.js      // Cart Component
-------- FluxCartApp.react.js   // Main Controller View
-------- FluxProduct.react.js   // Product Component
---- constants/
-------- FluxCartConstants.js   // Our app's action constants
---- dispatcher/
-------- AppDispatcher.js       // Our app's dispatcher
---- stores/
-------- CartStore.js           // Cart Store
-------- ProductStore.js            // Product Store
---- utils/
-------- CartAPI.js             // Mock API
---- app.js                     // Main app.js file
---- ProductData.js             // Mock Data
index.html
package.json

下面,來看看我們的 package.json 文件。我們會用到下面這些模塊:

  • Browserify
  • Reactify
  • React
  • Flux
  • Watchify
  • Uglify
  • Underscore
  • Envify

我們可以直接執行 npm install 來安裝所有的依賴項,然後執行 npm start 指令來啟動進程,監控我們的工程以及當我們保存修改的時候進行打包。

package.json

<!-- lang: js -->
{
  "name": "flux-pricing",
  "version": "0.0.1",
  "description": "Pricing component with flux",
  "main": "js/app.js",
  "dependencies": {
    "flux": "^2.0.0",
    "react": "^0.12.0",
    "underscore": "^1.7.0"
  },
  "devDependencies": {
    "browserify": "~6.2.0",
    "envify": "~3.0.0",
    "react": "^0.12.0",
    "reactify": "^0.15",
    "watchify": "~2.1.0"
  },
  "scripts": {
    "start": "watchify -o js/bundle.js -v -d .",
    "build": "browserify . | uglifyjs -cm > js/bundle.min.js"
  },
  "author": "Ken Wheeler",
  "browserify": {
    "transform": [
      "reactify",
      "envify"
    ]
  }
}

API & 模擬數據

為了保持我們可以專注在 Flux & React 上,我們將使用模擬 API 和模擬數據來演示我們的工程。也就是說,雖然數據是假的,但是 API 本身是和真正的 API 一致的,當我們需要用真的 API 的時候,可以很容易的就遷移過去。

讓我們來看看模擬的產品數據是怎樣的:

ProductData.js

<!-- lang: js -->
module.exports = {
  // Load Mock Product Data Into localStorage
  init: function() {
    localStorage.clear();
    localStorage.setItem('product', JSON.stringify([
      {
        id: '0011001',
        name: 'Scotch.io Signature Lager',
        image: 'scotch-beer.png',
        description: 'The finest lager money can buy. Hints of keyboard aerosol, with a whiff of iKlear wipes on the nose. If you pass out while drinking this beverage, Chris Sevilleja personally tucks you in.',
        variants: [
          {
            sku: '123123',
            type: '40oz Bottle',
            price: 4.99,
            inventory: 1

          },
          {
            sku: '123124',
            type: '6 Pack',
            price: 12.99,
            inventory: 5
          },
          {
            sku: '1231235',
            type: '30 Pack',
            price: 19.99,
            inventory: 3
          }
        ]
      }
    ]));
  }

};

就像你看到的那樣,我們定義了一個產品,它有個叫做 variants 的選項。我們的 schema 還反映了數據的類型,你可以通過簡單的調用一個 RESTful API 來拿到數據。我們準備把這個數據放到 localStorage,這樣我們的模擬 API 可以拿到並且在我們的應用中使用它們。

來看我們的模擬 API 是怎樣從 localStorage 獲取數據的,然後又是怎樣用 Flux action 把這些數據發送到我們的 ProductStore 中的 :

CartAPI.js

<!-- lang: js -->
var FluxCartActions = require('../actions/FluxCartActions');

module.exports = {

  // Load mock product data from localStorage into ProductStore via Action
  getProductData: function() {
    var data = JSON.parse(localStorage.getItem('product'));
    FluxCartActions.receiveProduct(data);
  }

};

好了,現在我們又了我們的樣本產品數據,有了樣本 API,我們該怎樣 利用它們開始我們的應用。

要初始化我們的數據,啟動我們的 API 調用,然後掛載我們的 controller view 真的是非常簡單。我們的 app.js 文件,如下,是用來處理這個流程的:

app.js

<!-- lang: js -->
window.React = require('react');
var ProductData = require('./ProductData');
var CartAPI = require('./utils/CartAPI')
var FluxCartApp = require('./components/FluxCartApp.react');

// Load Mock Product Data into localStorage
ProductData.init();

// Load Mock API Call
CartAPI.getProductData();

// Render FluxCartApp Controller View
React.render(
  <FluxCartApp />,
  document.getElementById('flux-cart')
);

Dispatcher

因為我們這個應用要用到 Flux ,我們需要創建我們自己的臉書 Dispatcher 庫。我們還要添加一個 handleAction 幫助方法到我們的 Dispatcher 實例,這樣才我們可以確定 action 的來源。

雖然我們當前的應用沒有明確要求這樣做,但是如果我們想把它掛到真正 API 上,或者處理一些從 View 以外來的動作的話,這將是極好的一個解決方法。

AppDispatcher.js

<!-- lang: js -->
var Dispatcher = require('flux').Dispatcher;

// Create dispatcher instance
var AppDispatcher = new Dispatcher();

// Convenience method to handle dispatch requests
AppDispatcher.handleAction = function(action) {
  this.dispatch({
    source: 'VIEW_ACTION',
    action: action
  });
}

module.exports = AppDispatcher;

在我們的 handleAction 方法裏面,我們從 action creator 接收到一個 action,然後我們的的 Dispatcher 把 action 和一個 source 屬性一起推送出去,這樣 action 就像一個參數一樣可以被使用了。

Actions

現在我們已經完成了依賴項目,數據和我們的 Dispatcher,現在開始來看看我們的工程的實際需求。Action 是很好的起點。讓我們先定義好 action 常量,都是應用要用到的機能:

FluxCartConstants.js

<!-- lang: js -->
var keyMirror = require('react/lib/keyMirror');

// Define action constants
module.exports = keyMirror({
  CART_ADD: null,       // Adds item to cart
  CART_REMOVE: null,    // Remove item from cart
  CART_VISIBLE: null,   // Shows or hides the cart
  SET_SELECTED: null,   // Selects a product option
  RECEIVE_DATA: null    // Loads our mock data
});

定義好我們的常量,我們需要創建實際的 action 方法。這些方法將會在我們的 視圖/組件 裏面被調用,然後告訴我們的 Dispatcher 廣播推送 action 到 Stores

action 本身包含了我們所需要的 action 常量和數據。我們的 Stores 然後會觸發更新事件,這些事件被 Controller View 監聽,這樣它們就知道什麼時候該更新狀態了。

下面,來看看我們是怎樣用 Dispatcher 的 handleAction 方法來傳遞 actionType 常量和關聯數據到 Dispatcher 的:

FluxCartActions.js

<!-- lang: js -->
var AppDispatcher = require('../dispatcher/AppDispatcher');
var FluxCartConstants = require('../constants/FluxCartConstants');

// Define actions object
var FluxCartActions = {

  // Receive inital product data
  receiveProduct: function(data) {
    AppDispatcher.handleAction({
      actionType: FluxCartConstants.RECEIVE_DATA,
      data: data
    })
  },

  // Set currently selected product variation
  selectProduct: function(index) {
    AppDispatcher.handleAction({
      actionType: FluxCartConstants.SELECT_PRODUCT,
      data: index
    })
  },

  // Add item to cart
  addToCart: function(sku, update) {
    AppDispatcher.handleAction({
      actionType: FluxCartConstants.CART_ADD,
      sku: sku,
      update: update
    })
  },

  // Remove item from cart
  removeFromCart: function(sku) {
    AppDispatcher.handleAction({
      actionType: FluxCartConstants.CART_REMOVE,
      sku: sku
    })
  },

  // Update cart visibility status
  updateCartVisible: function(cartVisible) {
    AppDispatcher.handleAction({
      actionType: FluxCartConstants.CART_VISIBLE,
      cartVisible: cartVisible
    })
  }

};

module.exports = FluxCartActions;

Stores

現在我們有了 Actions 的定義,下面該來創建 Stores 了。每個 Store 管理著應用中特定部分的狀態,因此我們將為我們的產品和購物車分別創建一個。讓我們先從 ProductStore 下手:

ProductStore.js

<!-- lang: js -->
var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;
var FluxCartConstants = require('../constants/FluxCartConstants');
var _ = require('underscore');

// Define initial data points
var _product = {}, _selected = null;

// Method to load product data from mock API
function loadProductData(data) {
  _product = data[0];
  _selected = data[0].variants[0];
}

// Method to set the currently selected product variation
function setSelected(index) {
  _selected = _product.variants[index];
}


// Extend ProductStore with EventEmitter to add eventing capabilities
var ProductStore = _.extend({}, EventEmitter.prototype, {

  // Return Product data
  getProduct: function() {
    return _product;
  },

  // Return selected Product
  getSelected: function(){
    return _selected;
  },

  // Emit Change event
  emitChange: function() {
    this.emit('change');
  },

  // Add change listener
  addChangeListener: function(callback) {
    this.on('change', callback);
  },

  // Remove change listener
  removeChangeListener: function(callback) {
    this.removeListener('change', callback);
  }

});

// Register callback with AppDispatcher
AppDispatcher.register(function(payload) {
  var action = payload.action;
  var text;

  switch(action.actionType) {

    // Respond to RECEIVE_DATA action
    case FluxCartConstants.RECEIVE_DATA:
      loadProductData(action.data);
      break;

    // Respond to SELECT_PRODUCT action
    case FluxCartConstants.SELECT_PRODUCT:
      setSelected(action.data);
      break;

    default:
      return true;
  }

  // If action was responded to, emit change event
  ProductStore.emitChange();

  return true;

});

module.exports = ProductStore;

上面,我們定義了兩個私有方法,loadProductDatasetSelected。我們用 loadProductData 來,當然,加載我們的模擬數據到 _product 對象。我們的 setSelected 方法用來設置當前選中的產品。

我們用方法 getProductgetSelected 來暴露數據,返回它們各自當前內部數據。在 View 裏面,用 require 我們的 Store 之後,這些方法就可以被利用了。

最後,我們註冊一個囘調到我們的 AppDispatcher ,用 switch 來匹配檢查是否拿到的 action 是我們支持的種類。在它的事件中,我們調用私有方法拿到 action 的數據,然後激活 change 事件,強制我們的 view 用新的狀態刷新。

下面,我們開始做我們的 CartStore

<!-- lang: js -->
var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;
var FluxCartConstants = require('../constants/FluxCartConstants');
var _ = require('underscore');

// Define initial data points
var _products = {}, _cartVisible = false;

// Add product to cart
function add(sku, update) {
  update.quantity = sku in _products ? _products[sku].quantity + 1 : 1;
  _products[sku] = _.extend({}, _products[sku], update)
}

// Set cart visibility
function setCartVisible(cartVisible) {
  _cartVisible = cartVisible;
}

// Remove item from cart
function removeItem(sku) {
  delete _products[sku];
}

// Extend Cart Store with EventEmitter to add eventing capabilities
var CartStore = _.extend({}, EventEmitter.prototype, {

  // Return cart items
  getCartItems: function() {
    return _products;
  },

  // Return # of items in cart
  getCartCount: function() {
    return Object.keys(_products).length;
  },

  // Return cart cost total
  getCartTotal: function() {
    var total = 0;
    for(product in _products){
      if(_products.hasOwnProperty(product)){
        total += _products[product].price * _products[product].quantity;
      }
    }
    return total.toFixed(2);
  },

  // Return cart visibility state
  getCartVisible: function() {
    return _cartVisible;
  },

  // Emit Change event
  emitChange: function() {
    this.emit('change');
  },

  // Add change listener
  addChangeListener: function(callback) {
    this.on('change', callback);
  },

  // Remove change listener
  removeChangeListener: function(callback) {
    this.removeListener('change', callback);
  }

});

// Register callback with AppDispatcher
AppDispatcher.register(function(payload) {
  var action = payload.action;
  var text;

  switch(action.actionType) {

    // Respond to CART_ADD action
    case FluxCartConstants.CART_ADD:
      add(action.sku, action.update);
      break;

    // Respond to CART_VISIBLE action
    case FluxCartConstants.CART_VISIBLE:
      setCartVisible(action.cartVisible);
      break;

    // Respond to CART_REMOVE action
    case FluxCartConstants.CART_REMOVE:
      removeItem(action.sku);
      break;

    default:
      return true;
  }

  // If action was responded to, emit change event
  CartStore.emitChange();

  return true;

});

module.exports = CartStore;

如你所見,我們的這個 store 和 ProductStore 很像。我們用 _products 對象來存儲當前在購物車中的產品,用 _cartVisibility 來標記我們的購物車是否可見。

我們再添加了一些複雜的公共方法,允許我們的 Controller View 來處理應用的狀態:

  • getCartItems 返回購物車中的物品
  • getCartCount 返回購物車中的物品總件數
  • getCartTotal 返回購物車中的物品總價格

好了,Store 就是這樣。下面開始著手寫我們的視圖。

Controller View

我們的 Controller View 是頂級組件,它監聽著我們 stores ,並且根據狀態來調用 Stores 的公用方法。然後會通過 props 來把 state 傳給子控件。

Controller View 主要負責:

  • 通過調用 Store 的公用方法設置我們應用的狀態
  • 通過 props 屬性將 state 傳遞給子控件,操作子控件
  • 監聽 Store 的更新事件

FluxCartApp.react.js

<!-- lang: js -->
var React = require('react');
var CartStore = require('../stores/CartStore');
var ProductStore = require('../stores/ProductStore');
var FluxProduct = require('./FluxProduct.react');
var FluxCart = require('./FluxCart.react');

// Method to retrieve state from Stores
function getCartState() {
  return {
    product: ProductStore.getProduct(),
    selectedProduct: ProductStore.getSelected(),
    cartItems: CartStore.getCartItems(),
    cartCount: CartStore.getCartCount(),
    cartTotal: CartStore.getCartTotal(),
    cartVisible: CartStore.getCartVisible()
  };
}

// Define main Controller View
var FluxCartApp = React.createClass({

  // Get initial state from stores
  getInitialState: function() {
    return getCartState();
  },

  // Add change listeners to stores
  componentDidMount: function() {
    ProductStore.addChangeListener(this._onChange);
    CartStore.addChangeListener(this._onChange);
  },

  // Remove change listers from stores
  componentWillUnmount: function() {
    ProductStore.removeChangeListener(this._onChange);
    CartStore.removeChangeListener(this._onChange);
  },

  // Render our child components, passing state via props
  render: function() {
    return (
      <div className="flux-cart-app">
        <FluxCart products={this.state.cartItems} count={this.state.cartCount} total={this.state.cartTotal} visible={this.state.cartVisible} />
        <FluxProduct product={this.state.product} cartitems={this.state.cartItems} selected={this.state.selectedProduct} />
      </div>
    );
  },

  // Method to setState based upon Store changes
  _onChange: function() {
    this.setState(getCartState());
  }

});

module.exports = FluxCartApp;

我們從公有方法 getCartState 開始。我們用這個方法來調用 Stores 的共有方法,接收當前狀態然後設置應用的狀態。我們在 getInitialState 方法中執行第一次,然後在每次接收到 Store 的更新事件時也執行。

為了接收到這些更新事件,我們在加載的時候,往 Stores 追加監聽,這樣就可以知道什麼時候發生改變。同樣,在卸載組件的時候把這些事件移除。

在我們的 render 方法中,我們組合 FluxCartFluxProduct 兩個組件。在這裏,我們把 state 狀態傳給它們,通過組件的屬性或者 props。

Product View

接下來,開始美化我們的應用了,來看 Product View。我們希望用從 Controller View 拿到的狀態,做一個超級豐富的,有良好交互的產品展示。

那麽開始

FluxProduct.react.js

<!-- lang: js -->
var React = require('react');
var FluxCartActions = require('../actions/FluxCartActions');

// Flux product view
var FluxProduct = React.createClass({

  // Add item to cart via Actions
  addToCart: function(event){
    var sku = this.props.selected.sku;
    var update = {
      name: this.props.product.name,
      type: this.props.selected.type,
      price: this.props.selected.price
    }
    FluxCartActions.addToCart(sku, update);
    FluxCartActions.updateCartVisible(true);
  },

  // Select product variation via Actions
  selectVariant: function(event){
    FluxCartActions.selectProduct(event.target.value);
  },

  // Render product View
  render: function() {
    var ats = (this.props.selected.sku in this.props.cartitems) ?
      this.props.selected.inventory - this.props.cartitems[this.props.selected.sku].quantity :
      this.props.selected.inventory;
    return (
      <div className="flux-product">
        <img src={'img/' + this.props.product.image}/>
        <div className="flux-product-detail">
          <h1 className="name">{this.props.product.name}</h1>
          <p className="description">{this.props.product.description}</p>
          <p className="price">Price: ${this.props.selected.price}</p>
          <select onChange={this.selectVariant}>
            {this.props.product.variants.map(function(variant, index){
              return (
                <option key={index} value={index}>{variant.type}</option>
              )
            })}
          </select>
          <button type="button" onClick={this.addToCart} disabled={ats  > 0 ? '' : 'disabled'}>
            {ats > 0 ? 'Add To Cart' : 'Sold Out'}
          </button>
        </div>
      </div>
    );
  },

});

module.exports = FluxProduct;

來看我們的 render 方法,我們定義了一個 Action,用來把組件綁到指定元素上。通過導入我們的 Action,我們可以從這個方法裏面調用它,並觸發更新處理:

  • selectProduct 設置當前選中的產品選項
  • addToCart 添加當前選中的產品到購物車,並打開購物車

在我們的 render 方法裏,我們會算一下被選中的產品還有多少庫存,通過比較我們放到購物車裏面的和庫存。我們通過這樣的計算來更新 “放到購物車” 按鈕狀態。

Cart View

購物車總得有個車把東西裝在一起吧。在我們的應用中,當一個產品被放到購物車,我們用一條數據來表示它被選中,當然你可以增加數量,但是我們不會弄幾條重複的數據。然後再把總價給算出來。

就像下面這樣:

FluxCart.react.js

<!-- lang: js -->
var React = require('react');
var FluxCartActions = require('../actions/FluxCartActions');

// Flux cart view
var FluxCart = React.createClass({

  // Hide cart via Actions
  closeCart: function(){
    FluxCartActions.updateCartVisible(false);
  },

  // Show cart via Actions
  openCart: function(){
    FluxCartActions.updateCartVisible(true);
  },

  // Remove item from Cart via Actions
  removeFromCart: function(sku){
    FluxCartActions.removeFromCart(sku);
    FluxCartActions.updateCartVisible(false);
  },

  // Render cart view
  render: function() {
    var self = this, products = this.props.products;
    return (
      <div className={"flux-cart " + (this.props.visible ? 'active' : '')}>
        <div className="mini-cart">
          <button type="button" className="close-cart" onClick={this.closeCart}>×</button>
          <ul>
            {Object.keys(products).map(function(product){
              return (
                <li key={product}>
                  <h1 className="name">{products[product].name}</h1>
                  <p className="type">{products[product].type} x {products[product].quantity}</p>
                  <p className="price">${(products[product].price * products[product].quantity).toFixed(2)}</p>
                  <button type="button" className="remove-item" onClick={self.removeFromCart.bind(self, product)}>Remove</button>
                </li>
              )
            })}
          </ul>
          <span className="total">Total: ${this.props.total}</span>
        </div>
        <button type="button" className="view-cart" onClick={this.openCart} disabled={Object.keys(this.props.products).length > 0 ? "" : "disabled"}>View Cart ({this.props.count})</button>
      </div>
    );
  },

});

module.exports = FluxCart;

我們有車了!我們的購物車組件有三個方法:

  • closeCart 關閉購物車
  • openCart 打開購物車
  • removeFromCart 從購物車中拿掉

當渲染我們的購物車的時候,我們用 map 方法來渲染我們的每條數據。注意 <li> 標籤,我們添加了 key 屬性。這是一個特殊的屬性,在動態追加子節點到組件上的時候。在 React 內部是唯一標識,這樣它們就可以保持它們正確的狀態和順序了。如果我們刪除掉這個屬性,打開你的控制檯,你會看到 React 會拋出警告說,你的 key 沒有設置,你有可能在渲染的時候出異常。

為了讓我們的購物車切換打開關閉,我們要做的僅僅是通過 React 來操作 CSS,處理 active class。

總結

如果你跟著上面一步步做了,現在你可以點你的 index.html,然後你就能看到你的應用跑起來啦。要不然的話,你就直接看下面的 Demo 吧。多點幾次放到購物車,把庫存都買完,享受一下任性的快感,同時看看我們按鈕的狀態是怎樣變成售罄的,以及我們購物車的數據變化。

師傅領進門,你現在可以自己去看這個例子的遠嗎了,然後試著追加些新特效到你的購物車,比如說用 react-router 來試試看網格佈局,或者給每個產品多加幾個選項。來叉我,不要被蒼老師攔住。

這篇文章是學習 React 系列的最後一篇文章了,我希望大家看得很爽學得很爽。我堅信 2015 年將是 React 年,所以,用你從這裏學到的東西,去幹些酷酷的事情吧。

你可能感兴趣的:(react,Facebook)