Ken Wheeler (@ken_wheeler)
#簡介
歡迎來到學習 React 的第四章這也是最後一章!到現在,我們已經學習了怎樣利用 React 的 API 來創建狀態型組件,如何應用它們,以及如何運用臉書的 Flux 架構來工作的
今天我們將把所有的這一切放到一塊,來創建一個簡單的購物車應用。在現在的電商網站上,產品的詳細頁面相互依賴,而 React 有助於簡化並有效的組織它們。
如果你還沒準備好,我強烈建議你看看這個學習系列前面的三篇文章:
##ReactJS 0.12
在寫這系列文章的時候,React 的 0.12 版發布了,而且還有不少變化。這篇教程將會用 0.12 的語法來寫。這些改變包括以下這些:
- 用 JSX 語法的時候不再需要
/** @jsx React.DOM */
頭聲明了 renderComponent
改成render
renderComponentToString
改成renderToString
你可以在官方的的更新日志了解更多的改变。
#開始
我們搭建一個應用的第一步是首先確定我們應該做什麼。我們需要這樣:
- 顯示有幾個選項的產品
- 價格隨著不同選擇改變
- 追加物品到購物車
- 從購物車移除物品
- 顯示在購物車中商品個數
- 顯示在購物車中商品總價
- 根據選項顯示每種商品的小結
- 當沒有庫存的時候,把"放到購物車"改成"售罄"
- 點擊"查看購物車"之後跳轉購物車頁面
下面是完成圖:
這個應用將用純客戶端開發,原因是我不打算寫服務端。我們將會用模擬 API 以及模擬數據,這樣,我們可以集中在組件上面。下面來看看我們的目錄結構:
##目錄結構
css/---- app.cssimg/---- scotch-beer.pngjs/---- 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 Dataindex.htmlpackage.json
下面,來看看我們的 package.json
文件。我們會用到下面這些模塊:
- Browserify
- Reactify
- React
- Flux
- Watchify
- Uglify
- Underscore
- Envify
我們可以直接執行 npm install
來安裝所有的依賴項,然後執行 npm start
指令來啟動進程,監控我們的工程以及當我們保存修改的時候進行打包。
###package.json
{ "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
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
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
window.React = require('react');var ProductData = require('./ProductData');var CartAPI = require('./utils/CartAPI')var FluxCartApp = require('./components/FluxCartApp.react');// Load Mock Product Data into localStorageProductData.init();// Load Mock API CallCartAPI.getProductData();// Render FluxCartApp Controller ViewReact.render(, document.getElementById('flux-cart'));
##Dispatcher
因為我們這個應用要用到 Flux ,我們需要創建我們自己的臉書 Dispatcher 庫。我們還要添加一個 handleAction
幫助方法到我們的 Dispatcher 實例,這樣才我們可以確定 action 的來源。
雖然我們當前的應用沒有明確要求這樣做,但是如果我們想把它掛到真正 API 上,或者處理一些從 View 以外來的動作的話,這將是極好的一個解決方法。
###AppDispatcher.js
var Dispatcher = require('flux').Dispatcher;// Create dispatcher instancevar AppDispatcher = new Dispatcher();// Convenience method to handle dispatch requestsAppDispatcher.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
var keyMirror = require('react/lib/keyMirror');// Define action constantsmodule.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
var AppDispatcher = require('../dispatcher/AppDispatcher');var FluxCartConstants = require('../constants/FluxCartConstants');// Define actions objectvar 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
var AppDispatcher = require('../dispatcher/AppDispatcher');var EventEmitter = require('events').EventEmitter;var FluxCartConstants = require('../constants/FluxCartConstants');var _ = require('underscore');// Define initial data pointsvar _product = {}, _selected = null;// Method to load product data from mock APIfunction loadProductData(data) { _product = data[0]; _selected = data[0].variants[0];}// Method to set the currently selected product variationfunction setSelected(index) { _selected = _product.variants[index];}// Extend ProductStore with EventEmitter to add eventing capabilitiesvar 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 AppDispatcherAppDispatcher.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;
上面,我們定義了兩個私有方法,loadProductData
和 setSelected
。我們用 loadProductData
來,當然,加載我們的模擬數據到 _product
對象。我們的 setSelected
方法用來設置當前選中的產品。
我們用方法 getProduct
和 getSelected
來暴露數據,返回它們各自當前內部數據。在 View 裏面,用 require
我們的 Store 之後,這些方法就可以被利用了。
最後,我們註冊一個囘調到我們的 AppDispatcher
,用 switch 來匹配檢查是否拿到的 action 是我們支持的種類。在它的事件中,我們調用私有方法拿到 action 的數據,然後激活 change 事件,強制我們的 view 用新的狀態刷新。
下面,我們開始做我們的 CartStore
:
var AppDispatcher = require('../dispatcher/AppDispatcher');var EventEmitter = require('events').EventEmitter;var FluxCartConstants = require('../constants/FluxCartConstants');var _ = require('underscore');// Define initial data pointsvar _products = {}, _cartVisible = false;// Add product to cartfunction add(sku, update) { update.quantity = sku in _products ? _products[sku].quantity + 1 : 1; _products[sku] = _.extend({}, _products[sku], update)}// Set cart visibilityfunction setCartVisible(cartVisible) { _cartVisible = cartVisible;}// Remove item from cartfunction removeItem(sku) { delete _products[sku];}// Extend Cart Store with EventEmitter to add eventing capabilitiesvar 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 AppDispatcherAppDispatcher.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
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 Storesfunction getCartState() { return { product: ProductStore.getProduct(), selectedProduct: ProductStore.getSelected(), cartItems: CartStore.getCartItems(), cartCount: CartStore.getCartCount(), cartTotal: CartStore.getCartTotal(), cartVisible: CartStore.getCartVisible() };}// Define main Controller Viewvar 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 (); }, // Method to setState based upon Store changes _onChange: function() { this.setState(getCartState()); }});module.exports = FluxCartApp;
我們從公有方法 getCartState
開始。我們用這個方法來調用 Stores 的共有方法,接收當前狀態然後設置應用的狀態。我們在 getInitialState
方法中執行第一次,然後在每次接收到 Store 的更新事件時也執行。
為了接收到這些更新事件,我們在加載的時候,往 Stores 追加監聽,這樣就可以知道什麼時候發生改變。同樣,在卸載組件的時候把這些事件移除。
在我們的 render
方法中,我們組合 FluxCart
和 FluxProduct
兩個組件。在這裏,我們把 state 狀態傳給它們,通過組件的屬性或者 props。
##Product View
接下來,開始美化我們的應用了,來看 Product View。我們希望用從 Controller View 拿到的狀態,做一個超級豐富的,有良好交互的產品展示。
那麽開始
###FluxProduct.react.js
var React = require('react');var FluxCartActions = require('../actions/FluxCartActions');// Flux product viewvar 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 (); },});module.exports = FluxProduct;{this.props.product.name}
{this.props.product.description}
Price: ${this.props.selected.price}
來看我們的 render
方法,我們定義了一個 Action,用來把組件綁到指定元素上。通過導入我們的 Action,我們可以從這個方法裏面調用它,並觸發更新處理:
selectProduct
設置當前選中的產品選項addToCart
添加當前選中的產品到購物車,並打開購物車
在我們的 render 方法裏,我們會算一下被選中的產品還有多少庫存,通過比較我們放到購物車裏面的和庫存。我們通過這樣的計算來更新 "放到購物車" 按鈕狀態。
##Cart View
購物車總得有個車把東西裝在一起吧。在我們的應用中,當一個產品被放到購物車,我們用一條數據來表示它被選中,當然你可以增加數量,但是我們不會弄幾條重複的數據。然後再把總價給算出來。
就像下面這樣:
###FluxCart.react.js
var React = require('react');var FluxCartActions = require('../actions/FluxCartActions');// Flux cart viewvar 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 (); },});module.exports = FluxCart;{Object.keys(products).map(function(product){ return (
Total: ${this.props.total}- ) })}
{products[product].name}
{products[product].type} x {products[product].quantity}
${(products[product].price * products[product].quantity).toFixed(2)}
我們有車了!我們的購物車組件有三個方法:
closeCart
關閉購物車openCart
打開購物車removeFromCart
從購物車中拿掉
當渲染我們的購物車的時候,我們用 map
方法來渲染我們的每條數據。注意 <li>
標籤,我們添加了 key
屬性。這是一個特殊的屬性,在動態追加子節點到組件上的時候。在 React 內部是唯一標識,這樣它們就可以保持它們正確的狀態和順序了。如果我們刪除掉這個屬性,打開你的控制檯,你會看到 React 會拋出警告說,你的 key
沒有設置,你有可能在渲染的時候出異常。
為了讓我們的購物車切換打開關閉,我們要做的僅僅是通過 React 來操作 CSS,處理 active
class。
#總結
如果你跟著上面一步步做了,現在你可以點你的 index.html
,然後你就能看到你的應用跑起來啦。要不然的話,你就直接看下面的 Demo 吧。多點幾次放到購物車,把庫存都買完,享受一下任性的快感,同時看看我們按鈕的狀態是怎樣變成售罄的,以及我們購物車的數據變化。
師傅領進門,你現在可以自己去看這個例子的遠嗎了,然後試著追加些新特效到你的購物車,比如說用 來試試看網格佈局,或者給每個產品多加幾個選項。來叉我,不要被蒼老師攔住。
這篇文章是學習 React 系列的最後一篇文章了,我希望大家看得很爽學得很爽。我堅信 2015 年將是 React 年,所以,用你從這裏學到的東西,去幹些酷酷的事情吧。