Reactjs101

GitHub - kdchang/reactjs101: 從零開始學 ReactJS(ReactJS 101)是一本希望讓初學者一看就懂的 ReactJS 中文入門教學書,由淺入深學習 ReactJS 生態系 (Flux, Redux, React Router, ImmutableJS, React Native, Relay/GraphQL etc.)。
Repo URL: https://github.com/kdchang/reactjs101
Edited by:
Cover image: Cover image
Share this using: email, Google+, Twitter, Facebook.
Exports: EPUB

1 附錄一、React ES5、ES6+ 常見用法對照表

一看就懂的 React ES5、ES6+ 常見用法對照表

1.1 前言

React 是 Facebook 推出的開源 JavaScript Library。自從 React 正式開源後,React 生態系開始蓬勃發展。事實上,透過學習 React 生態系(ecosystem)的過程中,可以讓我們順便學習現代化 Web 開發的重要觀念(例如:ES6、WebpackBabel、模組化等),成為更好的開發者。雖然 ES6(ECMAScript2015)、ES7 是未來趨勢(本文將 ES6、ES7 稱為 ES6+),然而目前在網路上有許多的學習資源仍是以 ES5 為主,導致讀者在學習上遇到一些坑洞和迷惑(本文假設讀者對於 React 已經有些基本認識,若你對於 React 尚不熟悉,建議先行閱讀官方文件本篇入門教學)。因此本文希望透過整理在 React 中 ES5、ES6+ 常見用法對照表,讓讀者們可以在實現功能時(尤其在 React Native)可以更清楚兩者的差異,無痛轉移到 ES6+。

1.2 大綱

  1. Modules
  2. Classes
  3. Method definition
  4. Property initializers
  5. State
  6. Arrow functions
  7. Dynamic property names & template strings
  8. Destructuring & spread attributes
  9. Mixins
  10. Default Parameters

1.3 1. Modules

隨著 Web 技術的進展,模組化開發已經成為一個重要課題。關於 JavaScript 模組化我們這邊不詳述,建議讀者參考 這份投影片這篇文章

ES5 若使用 CommonJS 標準,一般使用 require() 用法引入模組:

var React = require('react');
var MyComponent = require('./MyComponent');

輸出則是使用 module.exports

module.exports = MyComponent;

ES6+ import 用法:

import React from 'react';
import MyComponent from './MyComponent';

輸出則是使用 export default

export default class MyComponent extends React.Component {

}

1.4 2. Classes

在 React 中元件(Component)是組成視覺頁面的基礎。在 ES5 中我們使用 React.createClass() 來建立 Component,而在 ES6+ 則是用 Classes 繼承 React.Component 來建立 Component。若是有寫過 Java 等物件導向語言(OOP)的讀者應該對於這種寫法比較不陌生,不過要注意的是 JavaScript 仍是原型繼承類型的物件導向程式語言,只是使用 Classes 讓物件導向使用上更加直觀。對於選擇 class 使用上還有疑惑的讀者建議可以閱讀 React.createClass versus extends React.Component 這篇文章。

ES5 React.createClass() 用法:

var Photo = React.createClass({
  render: function() {
    return (
      <div>
        <images alt={this.props.description} src={this.props.src} />
      </div>
      );
  }
});
ReactDOM.render(<Photo />, document.getElementById('main'));

ES6+ class 用法:

class Photo extends React.Component {
  render() {
    return <images alt={this.props.description} src={this.props.src} />;
  }
}
ReactDOM.render(<Photo />, document.getElementById('main'));

在 ES5 我們會在 componentWillMount 生命週期定義希望在 render 前執行,且只會執行一次的任務:

var Photo = React.createClass({
  componentWillMount: function() {}
});

在 ES6+ 則是定義在 constructor 建構子中:

class Photo extends React.Component {
  constructor(props) {
    super(props);
    // 原本在 componentWillMount 操作的動作可以放在這
  }
}

1.5 3. Method definition

在 ES6 中我們使用 Method 可以忽略 function,,使用上更為簡潔!ES5 React.createClass() 用法:

var Photo = React.createClass({
  handleClick: function(e) {},
  render: function() {}
});

ES6+ class 用法:

class Photo extends React.Component {
  handleClick(e) {}
  render() {}
}

1.6 4. Property initializers

Component 屬性值是資料傳遞重要的元素,在 ES5 中我們使用 propTypesgetDefaultProps 來定義屬性(props)的預設值和型別:

var Todo = React.createClass({
  getDefaultProps: function() {
    return {
      checked: false,
      maxLength: 10,
    };
  },
  propTypes: {
    checked: React.PropTypes.bool.isRequired,
    maxLength: React.PropTypes.number.isRequired
  },
  render: function() {
    return();
  }
});

在 ES6+ 中我們則是參考 ES7 property initializers 使用 class 中的靜態屬性(static properties)來定義:

class Todo extends React.Component {
  static defaultProps = {
    checked: false,
    maxLength: 10,
  }; // 注意有分號
  static propTypes = {
    checked: React.PropTypes.bool.isRequired,
    maxLength: React.PropTypes.number.isRequired
  };
  render() {
    return();
  }
}

ES6+ 另外一種寫法,可以留意一下,主要是看各團隊喜好和規範,選擇合適的方式:

class Todo extends React.Component {
    render() {
        return (
            <View />
        );
    }
}
Todo.defaultProps = {
    checked: false,
    maxLength: 10,
};
Todo.propTypes = {
    checked: React.PropTypes.bool.isRequired,
    maxLength: React.PropTypes.number.isRequired,
};

1.7 5. State

在 React 中 PropsState 是資料流傳遞的重要元素,不同的是 state 可更動,可以去執行一些運算。在 ES5 中我們使用 getInitialState 去初始化 state

var Todo = React.createClass({
    getInitialState: function() {
        return {
            maxLength: this.props.maxLength,
        };
    },
});

在 ES6+ 中我們初始化 state 有兩種寫法:

class Todo extends React.Component {
    state = {
        maxLength: this.props.maxLength,
    }
}

另外一種寫法,使用在建構式初始化。比較推薦使用這種方式,方便做一些運算:

class Todo extends React.Component {
    constructor(props){
        super(props);
        this.state = {
            maxLength: this.props.maxLength,
        };
    }
}

1.8 6. Arrow functions

在講 Arrow functions 之前,我們先聊聊在 React 中 this 和它所代表的 context。在 ES5 中,我們使用 React.createClass() 來建立 Component,而在 React.createClass() 下,預設幫你綁定好 methodthis,你毋須自行綁定。所以你可以看到像是下面的例子,callback function handleButtonClick 中的 this 是指到 component 的實例(instance),而非觸發事件的物件:

var TodoBtn = React.createClass({
    handleButtonClick: function(e) {
        // 此 this 指到 component 的實例(instance),而非 button
        this.setState({showOptionsModal: true});
    },
    render: function(){
        return (
            <div>
                <Button onClick={this.handleButtonClick}>{this.props.label}</Button>
            </div>
        )
    },
});

然而自動綁定這種方式反而會讓人容易誤解,所以在 ES6+ 推薦使用 bind 綁定 this 或使用 Arrow functions(它會绑定當前 scopethis context)兩種方式,你可以參考下面例子:

class TodoBtn extends React.Component
{
    handleButtonClick(e){
        // 確認綁定 this 指到 component instance
        this.setState({toggle: true});
    }
    render(){
        // 這邊可以用 this.handleButtonClick.bind(this) 手動綁定或是 Arrow functions () => {} 用法
        return (
            <div>
                <Button onClick={this.handleButtonClick.bind(this)} onClick={(e)=> {this.handleButtonClick(e)} }>{this.props.label}</Button>
            </div>
        )
    },
}

Arrow functions 雖然一開始看起來有點怪異,但其實觀念很簡單:一個簡化的函數。函數基本上就是參數(不一定要有參數)、表達式、回傳值(也可能是回傳 undefined):

// Arrow functions 的一些例子
()=>7
e=>e+2
()=>{
    alert('XD');
}
(a,b)=>a+b
e=>{
    if (e == 2){
        return 2;
    }
    return 100/e;
}

不過要注意的是無論是 bind 或是 Arrow functions,每次執行回傳都是指到一個新的函數,若需要再調用到這個函數,請記得先把它存起來:

錯誤用法:

class TodoBtn extends React.Component{
    componentWillMount(){
        Btn.addEventListener('click', this.handleButtonClick.bind(this));
    }
    componentDidmount(){
        Btn.removeEventListener('click', this.handleButtonClick.bind(this));
    }
    onAppPaused(event){
    }
}

正確用法:

class TodoBtn extends React.Component{
    constructor(props){
        super(props);
        this.handleButtonClick = this.handleButtonClick.bind(this);
    }
    componentWillMount(){
        Btn.addEventListener('click', this.handleButtonClick);
    }
    componentDidMount(){
        Btn.removeEventListener('click', this.handleButtonClick);
    }
}

更多 Arrows and Lexical This 特性可以參考這個文件

1.9 7. Dynamic property names & template strings

以前在 ES5 我們要動態設定屬性名稱時,往往需要多寫幾行程式碼才能達到目標:

var Todo = React.createClass({
  onChange: function(inputName, e) {
    var stateToSet = {};
    stateToSet[inputName + 'Value'] = e.target.value;
    this.setState(stateToSet);
  },
});

但在 ES6+中,透過 enhancements to object literalstemplate strings 可以輕鬆完成動態設定屬性名稱的任務:

class Todo extends React.Component {
  onChange(inputName, e) {
    this.setState({
      [`${inputName}Value`]: e.target.value,
    });
  }
}

Template Strings 是一種語法糖(syntactic sugar),方便我們組織字串(這邊也用上 letconst 變數和常數宣告的方式,和 varfunction scope 不同的是它們是屬於 block scope,亦即生存域存在於 {} 間):

// Interpolate variable bindings
const name = "Bob", let = "today";
`Hello ${name}, how are you ${time}?` \\ Hello Bob, how are you today?

1.10 8. Destructuring & spread attributes

在 React 的 Component 中,父元件利用 props 來傳遞資料到子元件是常見作法,然而我們有時會希望只傳遞部分資料,此時 ES6+ 中的 DestructuringJSX 的 Spread Attributes... Spread Attributes 主要是用來迭代物件:

class Todo extends React.Component {
  render() {
    var {
      className,
      ...others,  // ...others 包含 this.props 除了 className 外所有值。this.props = {value: 'true', title: 'header', className: 'content'}
    } = this.props;
    return (
      <div className={className}>
        <TodoList {...others} />
        <button onClick={this.handleLoadMoreClick}>Load more</button>
      </div>
    );
  }
}

但使用上要注意的是若是有重複的屬性值則以後來覆蓋,下面的例子中若 ...this.props,有 className,則被後來的 main 所覆蓋:

<div {...this.props} className="main">
  …
</div>

Destructuring 也可以用在簡化 Module 的引入上,這邊我們先用 ES5 中引入方式來看:

var React = require('react-native');
var Component = React.component;

class HelloWorld extends Component {
  render() {
    return (
      <View>
        <Text>Hello, world!</Text>
      </View>
    );
  }
}

export default HelloWorld;

以下 ES5 寫法:

var React = require('react-native');
var View = React.View;

在 ES6+ 則可以直接使用 Destructuring 這種簡化方式來引入模組中的元件:

// 這邊等於上面的寫法
var { View } = require('react-native');

更進一步可以使用 import 語法:

import React, {
  View,
  Component,
  Text,
} from 'react-native';

class HelloWorld extends Component {
  render() {
    return (
      <View>
        <Text>Hello, world!</Text>
      </View>
    );
  }
}

export default HelloWorld;

1.11 9. Mixins

在 ES5 中,我們可以使用 Mixins 的方式去讓不同的 Component 共用相似的功能,重用我們的程式碼:

var PureRenderMixin = require('react-addons-pure-render-mixin');
React.createClass({
  mixins: [PureRenderMixin],

  render: function() {
    return <div className={this.props.className}>foo</div>;
  }
});

但由於官方不打算在 ES6+ 中繼續推行 Mixins,若還是希望使用,可以參考看看第三方套件或是這個文件的用法

1.12 10. Default Parameters

以前 ES5 我們函數要使用預設值需要這樣使用:

var link = function (height, color) {  
    var height = height || 50;  
    var color = color || 'red';  
}  

現在 ES6+ 的函數可以支援預設值,讓程式碼更為簡潔:

var link = function(height = 50, color = 'red') {  
  ...  
}

1.13 總結

以上就是 React ES5、ES6+常見用法對照表,能看到這邊的你應該已經對於 React ES5、ES6 使用上有些認識,先給自己一些掌聲吧!確實從 ES6 開始,JavaScript 和以前我們看到的 JavaScript 有些不同,增加了許多新的特性,有些讀者甚至會很懷疑說這真的是 JavaScript 嗎?ES6 的用法對於初學者來說可能會需要寫一點時間吸收,下一章我們將進到同樣也是有革新性設計和有趣的 React Native,用 JavaScript 和 React 寫 Native App!

1.14 延伸閱讀

  1. React/React Native 的ES5 ES6写法对照表
  2. React on ES6+
  3. react native 中es6语法解析
  4. Learn ES2015
  5. ECMAScript 6入门
  6. React官方網站
  7. React INTRO TO REACT.JS
  8. React.createClass versus extends React.Component
  9. react-native-coding-style
  10. Airbnb React/JSX Style Guide
  11. ECMAScript 6入门

1.15 🚪 任意門

回首頁 | 上一章:用 React + Redux + Node(Isomorphic JavaScript)開發食譜分享網站 | 下一章:用 React Native + Firebase 開發跨平台行動應用程式 |

勘誤、提問或許願 |

2 附錄二、用 React Native + Firebase 開發跨平台行動應用程式

用 React Native + Firebase 開發跨平台行動應用程式

2.1 前言

跨平台(Wirte once, Run Everywhere)一直以來是軟體工程的聖杯。過去一段時間市場上有許多嘗試跨平台開發原生行動裝置(Native Mobile App)的解決方案,嘗試運用 HTML、CSS 和 JavaScript 等網頁前端技術達到跨平台的效果,例如:運用 jQuery MobileIonicFramework7 等 Mobile UI 框架(Framework)結合 JavaScript 框架並搭配 Cordova/PhoneGap 進行跨平台行動裝置開發。然而,因為這些解決方案通常都是運行在 WebView 之上,導致效能和體驗要真正趨近於原生應用程式(Native App)還有一段路要走。

不過,隨著 Facebook 工程團隊開發的 React Native 橫空出世,想嘗試跨平台解決方案的開發者又有了新的選擇。

2.2 React Native 特色

在正式開始開發 React Native App 之前我們先來介紹一下 React Native 的主要特色:

  1. 使用 JavaScript(ES6+)和 React 打造跨平台原生應用程式(Learn once, write anywhere)
  2. 使用 Native Components,更貼近原生使用者體驗
  3. 在 JavaScript 和 Native 之間的操作為非同步(Asynchronous)執行,並可用 Chrome 開發者工具除錯,支援 Hot Reloading
  4. 使用 Flexbox 進行排版和布局
  5. 良好的可擴展性(Extensibility),容易整合 Web 生態系標準(XMLHttpRequest、 navigator.geolocation 等)或是原生的元件或函式庫(Objective-C、Java 或 Swift)
  6. Facebook 已使用 React Native 於自家 Production App 且將持續維護,另外也有持續蓬勃發展的技術社群
  7. 讓 Web 開發者可以使用熟悉的技術切入 Native App 開發
  8. 2015/3 釋出 iOS 版本,2015/9 釋出 Android 版本
  9. 目前更新速度快,平均每兩週發佈新的版本。社群也還持續在尋找最佳實踐,關於版本進展可以參考這個文件
  10. 支援的作業系統為 >= Android 4.1 (API 16) 和 >= iOS 7.0

2.3 React Native 初體驗

在了解了 React Native 特色後,我們準備開始開發我們的 React Native 應用程式!由於我們的範例可以讓程式跨平台共用,所以你可以使用 iOS 和 Android 平台運行。不過若是想在 iOS 平台開發需要先準備 Mac OS 和安裝 Xcode 開發工具,若是你準備使用 Android 平台的話建議先行安裝 Android StudioGenymotion 模擬器。在我們範例我們使用筆者使用的 MacO OS 作業系統並使用 Android 平台為主要範例,若有其他作業系統需求的讀者可以參考 官方安裝說明

一開始請先安裝 NodeWatchman 和 React Native command line 工具:

// 若你使用 Mac OS 你可以使用官網安裝方式或是使用 homebrew 安裝
$ brew install node
// watchman 可以監看檔案是否有修改
$ brew install watchman
// 安裝 React Native command line 工具
$ npm install -g react-native-cli

由於我們是要開發 Android 平台,所以必須安裝:

  1. 安裝 JDK
  2. 安裝 Android SDK
  3. 設定一些環境變數

以上可以透過 Install Android Studio 官網和 官方安裝說明 步驟完成。

現在,我們先透過一個簡單的 HelloWorldApp,讓大家感受一下 React Native 專案如何開發。

首先,我們先初始化一個 React Native Project:

$ react-native init HelloWorldApp

初始的資料夾結構長相:

用 React Native + Firebase 開發跨平台行動應用程式

接下來請先安裝註冊 Genymotion,Genymotion 是一個透過電腦模擬 Android 系統的好用開發模擬器環境。安裝完後可以打開並選擇欲使用的螢幕大小和 API 版本的 Android 系統。建立裝置後就可以啟動我們的裝置:

用 React Native + Firebase 開發跨平台行動應用程式

若你是使用 Mac OS 作業系統的話可以執行 run-ios,若是使用 Android 平台則使用 run-android 啟動你的 App。在這邊我們先使用 Android 平台進行開發(若你希望實機測試,請將電腦接上你的 Android 手機,記得確保 menu 中的 ip 位置要和電腦網路 相同。若是遇到連不到程式 server 且手機為 Android 5.0+ 系統,可以執行 adb reverse tcp:8081 tcp:8081,詳細情形可以參考官網說明):

$ react-native run-android

如果一切順利的話就可以在模擬器中看到初始畫面:

用 React Native + Firebase 開發跨平台行動應用程式

接著打開 index.android.js 就可以看到以下程式碼:

import React, { Component } from 'react';
import {
  AppRegistry,
  StyleSheet,
  Text,
  View
} from 'react-native';

// 元件式的開發方式和 React 如出一轍,但要注意的是在 React Native 中我們不使用 HTML 元素而是使用 React Native 元件進行開發,這也符合 Learn once, write anywhere 的原則。
class HelloWorldApp extends Component {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.welcome}>
          Welcome to React Native!
        </Text>
        <Text style={styles.instructions}>
          To get started, edit index.android.js
        </Text>
        <Text style={styles.instructions}>
          Double tap R on your keyboard to reload,{'\n'}
          Shake or press menu button for dev menu
        </Text>
      </View>
    );
  }
}

// 在 React Native 中 styles 是使用 JavaScript 形式來撰寫,與一般 CSS 比較不同的是他使用駝峰式的屬性命名:
const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F5FCFF',
  },
  welcome: {
    fontSize: 20,
    textAlign: 'center',
    margin: 10,
  },
  instructions: {
    textAlign: 'center',
    color: '#333333',
    marginBottom: 5,
  },
});

// 告訴 React Native App 你的進入點:
AppRegistry.registerComponent('HelloWorldApp', () => HelloWorldApp);

由於 React Native 有支援 Hot Reloading,若我們更改了檔案內容,我們可以使用打開模擬器 Menu 重新刷新頁面,此時就可以在看到原本的 Welcome to React Native! 文字已經改成 Welcome to React Native Rock!!!!

用 React Native + Firebase 開發跨平台行動應用程式

用 React Native + Firebase 開發跨平台行動應用程式

嗯,有沒有感覺在開發網頁的感覺?

2.4 動手實作

相信看到這裡讀者們一定等不及想大展身手,使用 React Native 開發你第一個 App。俗話說學習一項新技術最好的方式就是做一個 TodoApp。所以,接下來的文章,筆者將帶大家使用 React Native 結合 Redux/ImmutableJS 和 Firebase 開發一個記錄和刪除名言佳句(Mottos)的 Mobile App!

2.4.1 專案成果截圖

用 React Native + Firebase 開發跨平台行動應用程式

用 React Native + Firebase 開發跨平台行動應用程式

2.4.2 環境安裝與設定

相關套件安裝:

$ npm install --save redux react-redux immutable redux-immutable redux-actions uuid firebase
$ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react babel-preset-react-native eslint-plugin-react-native  eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react redux-logger

安裝完相關工具後我們可以初始化我們專案:

// 注意專案不能使用 - 或 _ 命名
$ react-native init ReactNativeFirebaseMotto
$ cd ReactNativeFirebaseMotto

我們先準備一下我們資料夾架構,將它設計成:

用 React Native + Firebase 開發跨平台行動應用程式

2.4.3 Firebase 簡介與設定

在這個專案中我們會使用到 Firebase 這個 Back-End as Service的服務,也就是說我們不用自己建立後端程式資料庫,只要使用 Firebase 所提供的 API 就好像有了一個 NoSQL 資料庫一樣,當然 Firebase 不單只有提供資料儲存的功能,但限於篇幅我們這邊將只介紹資料儲存的功能。

  1. 首先我們進到 Firebase 首頁
    用 React Native + Firebase 開發跨平台行動應用程式

  2. 登入後點選建立專案,依照自己想取的專案名稱命名

用 React Native + Firebase 開發跨平台行動應用程式

  1. 選擇將 Firebase 加入你的網路應用程式的按鈕可以取得 App ID 的 config 資料,待會我們將會使用到

用 React Native + Firebase 開發跨平台行動應用程式

  1. 點選左邊選單中的 Database 並點選 Realtime Database Tab 中的規則

用 React Native + Firebase 開發跨平台行動應用程式

設定改為,在範例中為求簡單,我們先不用驗證方式即可操作:

javascript { "rules": { ".read": true, ".write": true } }

Firebase 在使用上有許多優點,其中一個使用 Back-End As Service 的好處是你可以專注在應用程式的開發便免花過多時間處理後端基礎建設的部份,更可以讓 Back-End 共用在不同的 client side 中。此外 Firebase 在和 React 整合上也十分容易,你可以想成 Firebase 負責資料的儲存,透過 API 和 React 元件互動,Redux 負責接收管理 client state,若是監聽到 Firebase 後端資料更新後同步更新 state 並重新 render 頁面。

2.4.4 使用 Flexbox 進行 UI 布局設計

在 React Native 中是使用 Flexbox 進行排版,若讀者對於 Flexbox 尚不熟悉,建議可以參考這篇文章,若有需要遊戲化的學習工具,也非常推薦這兩個教學小遊戲:FlexDefenseFLEXBOX FROGGY

事實上我們可以將 Flexbox 視為一個箱子,最外層是 flex containers、內層包的是 flex items,在屬性上也有分是針對flex containers 還是針對是 flex items 設計的。在方向性上由左而右是 main axis,而上到下是 cross axis

用 React Native + Firebase 開發跨平台行動應用程式

在 Flexbox 有許多屬性值,其中最重要的當數 justifyContentalignItems 以及 flexDirection(注意 React Native Style 都是駝峰式寫法),所以我們這邊主要介紹這三個屬性:

Flex Direction 負責決定整個 flex containers 的方向,預設為 row 也可以改為 columnrow-reversecolumn-reverse

用 React Native + Firebase 開發跨平台行動應用程式

Justify Content 負責決定整個 flex containers 內的 items 的水平擺設,主要屬性值有:flex-startflex-endcenterspace-betweenspace-around

用 React Native + Firebase 開發跨平台行動應用程式

Align Items 負責決定整個 flex containers 內的 items 的垂直擺設,主要屬性值有:flex-startflex-endcenterstretchbaseline

用 React Native + Firebase 開發跨平台行動應用程式

2.5 動手實作

有了前面的準備,現在我們終於要開始進入核心的應用程式開發了!

首先我們先設定好整個 App 的進入檔 index.android.js,在這個檔案中我們設定了初始化的設定和主要元件 <Main />

/**
 * Sample React Native App
 * https://github.com/facebook/react-native
 * @flow
 */

import React, { Component } from 'react';
import {
  AppRegistry,
  Text,
  View
} from 'react-native';
import Main from './src/components/Main';

class ReactNativeFirebaseMotto extends Component {
  render() {
    return (
      <Main />
    );
  }
}

AppRegistry.registerComponent('ReactNativeFirebaseMotto', () => ReactNativeFirebaseMotto);

src/components/Main/Main.js 中我們設定好整個 Component 的布局和並將 Firebase 引入並初始化,將操作 Firebase 資料庫的參考往下傳,根節點我們命名為 items,所以之後所有新增的 motto 都會在這個根節點之下並擁有特定的 key 值。在 Main 我們同樣規劃了整個布局,包括:<ToolBar /><MottoListContainer /><ActionButtonContainer /><InputModalContainer />

import React from 'react';
import ReactNative from 'react-native';
import { Provider } from 'react-redux'; 
import ToolBar from '../ToolBar';
import MottoListContainer from '../../containers/MottoListContainer';
import ActionButtonContainer from '../../containers/ActionButtonContainer';
import InputModalContainer from '../../containers/InputModalContainer';
import ListItem from '../ListItem';
import * as firebase from 'firebase';
// 將 Firebase 的 config 值引入
import { firebaseConfig } from '../../constants/config';
// 引用 Redux store
import store from '../../store';
const { View, Text } = ReactNative;

// Initialize Firebase
const firebaseApp = firebase.initializeApp(firebaseConfig);
// Create a reference with .ref() instead of new Firebase(url)
const rootRef = firebaseApp.database().ref();
const itemsRef = rootRef.child('items');

// 將 Redux 的 store 透過 Provider 往下傳
const Main = () => (
  <Provider store={store}>
    <View>
      <ToolBar style={styles.toolBar} />
      <MottoListContainer itemsRef={itemsRef} />
      <ActionButtonContainer />
      <InputModalContainer itemsRef={itemsRef} />
    </View>
  </Provider>
);

export default Main; 

設定完了基本的布局方式後我們來設定 Actions 和其使用的常數,src/actions/mottoActions.js

export const GET_MOTTOS = 'GET_MOTTOS';
export const CREATE_MOTTO = 'CREATE_MOTTO';
export const SET_IN_MOTTO = 'SET_IN_MOTTO';
export const TOGGLE_MODAL = 'TOGGLE_MODAL';

我們在 constants 資料夾中也設定了我們整個 data 的資料結構,以下是 src/constants/models.js

import Immutable from 'immutable';

export const MottoState = Immutable.fromJS({
  mottos: [],
  motto: {
    id : '',
    text: '',
    updatedAt: '',
  }
});

export const UiState = Immutable.fromJS({
  isModalVisible: false,
});

還記得我們提到的 Firebase config 嗎?這邊我們把相關的設定檔放在src/configs/config.js中:

export const firebaseConfig = {
  apiKey: "apiKey",
  authDomain: "authDomain",
  databaseURL: "databaseURL",
  storageBucket: "storageBucket",
};

在我們應用程式中同樣使用了 reduxredux-actions。在這個範例中我們設計了:GET_MOTTOS、CREATE_MOTTO、SET_IN_MOTTO 三個操作 motto 的 action,分別代表從 Firebase 取出資料、新增資料和 set 資料。以下是 src/actions/mottoActions.js

import { createAction } from 'redux-actions';
import {
  GET_MOTTOS,
  CREATE_MOTTO,
  SET_IN_MOTTO,
} from '../constants/actionTypes';

export const getMottos = createAction('GET_MOTTOS');
export const createMotto = createAction('CREATE_MOTTO');
export const setInMotto = createAction('SET_IN_MOTTO');

同樣地,由於我們設計了當使用者想新增 motto 時會跳出 modal,所以我們可以設定一個 TOGGLE_MODAL 負責開關 modal 的 state。以下是 src/actions/uiActions.js

import { createAction } from 'redux-actions';
import {
  TOGGLE_MODAL,
} from '../constants/actionTypes';

export const toggleModal = createAction('TOGGLE_MODAL');

以下是 src/actions/index.js,用來匯出我們的 actions:

export * from './uiActions';
export * from './mottoActions';

設定完我們的 actions 後我們來設定 reducers,在這邊我們同樣使用 redux-actions 整合 ImmutableJS

import { handleActions } from 'redux-actions';
// 引入 initialState 
import { 
  MottoState
} from '../../constants/models';

import {
  GET_MOTTOS,
  CREATE_MOTTO,
  SET_IN_MOTTO,
} from '../../constants/actionTypes';

// 透過 set 和 seIn 可以產生 newState
const mottoReducers = handleActions({
  GET_MOTTOS: (state, { payload }) => (
    state.set(
      'mottos',
      payload.mottos
    )
  ),  
  CREATE_MOTTO: (state) => (
    state.set(
      'mottos',
      state.get('mottos').push(state.get('motto'))
    )
  ),
  SET_IN_MOTTO: (state, { payload }) => (
    state.setIn(
      payload.path,
      payload.value
    )
  )
}, MottoState);

export default mottoReducers;

以下是 src/reducers/uiState.js

import { handleActions } from 'redux-actions';
import { 
  UiState,
} from '../../constants/models';

import {
  TOGGLE_MODAL,
} from '../../constants/actionTypes';

// modal 的顯示與否
const uiReducers = handleActions({
  TOGGLE_MODAL: (state) => (
    state.set(
      'isModalVisible',
      !state.get('isModalVisible')
    )
  ),  
}, UiState);

export default uiReducers;

以下是 src/reducers/index.js,將所有 reducers combine 在一起:

import { combineReducers } from 'redux-immutable';
import ui from './ui/uiReducers';
import motto from './data/mottoReducers';

const rootReducer = combineReducers({
  ui,
  motto,
});

export default rootReducer;

透過 src/store/configureStore.js將 reducers 和 initialState 以及要使用的 middleware 整合成 store:

import { createStore, applyMiddleware } from 'redux';
import createLogger from 'redux-logger';
import Immutable from 'immutable';
import rootReducer from '../reducers';

const initialState = Immutable.Map();

export default createStore(
  rootReducer,
  initialState,
  applyMiddleware(createLogger({ stateTransformer: state => state.toJS() }))
);

設定完資料層的架構後,我們又重新回到 View 的部份,我們開始依序設定我們的 Component 和 Container。首先,我們先設計我們的標題列 ToolBar,以下是 src/components/ToolBar/ToolBar.js

import React from 'react';
import ReactNative from 'react-native';
import styles from './toolBarStyles';
const { View, Text } = ReactNative;

const ToolBar = () => (
  <View style={styles.toolBarContainer}>
    <Text style={styles.toolBarText}>Startup Mottos</Text>
  </View>
);

export default ToolBar; 

以下是 src/components/ToolBar/toolBarStyles.js,將底色設定為黃色,文字置中:

import { StyleSheet } from 'react-native';

export default StyleSheet.create({
  toolBarContainer: {
    height: 40,
    justifyContent: 'center',
    alignItems: 'center',
    flexDirection: 'column',
    backgroundColor: '#ffeb3b',
  },
  toolBarText: {
    fontSize: 20,
    color: '#212121'
  }
});

以下是 src/components/MottoList/MottoList.js,這個 Component 中稍微複雜一些,主要是使用到了 React Native 中的 ListView Component 將資料陣列傳進 dataSource,透過 renderRow 把一個個 row 給 render 出來,過程中我們透過 !Immutable.is(r1.get('id'), r2.get('id')) 去判斷整個 ListView 畫面是否需要 loading 新的 item 進來,這樣就可以提高整個 ListView 的效能。

import React, { Component } from 'react';
import ReactNative from 'react-native';
import Immutable from 'immutable';
import ListItem from '../ListItem';
import styles from './mottoStyles';
const { View, Text, ListView } = ReactNative;

class MottoList extends Component {
  constructor(props) {
    super(props);
    this.renderListItem = this.renderListItem.bind(this);
    this.listenForItems = this.listenForItems.bind(this);
    this.ds = new ListView.DataSource({
      rowHasChanged: (r1, r2) => !Immutable.is(r1.get('id'), r2.get('id')),
    })
  }
  renderListItem(item) {
    return (
      <ListItem item={item} onDeleteMotto={this.props.onDeleteMotto} itemsRef={this.props.itemsRef} />
    );
  }  
  listenForItems(itemsRef) {
    itemsRef.on('value', (snap) => {
      if(snap.val() === null) {
        this.props.onGetMottos(Immutable.fromJS([]));
      } else {
        this.props.onGetMottos(Immutable.fromJS(snap.val()));  
      }     
    });
  }
  componentDidMount() {
    this.listenForItems(this.props.itemsRef);
  }
  render() {
    return (
      <View>
        <ListView
          style={styles.listView}
          dataSource={this.ds.cloneWithRows(this.props.mottos.toArray())}
          renderRow={this.renderListItem}
          enableEmptySections={true}
        />
      </View>
    );
  }
}

export default MottoList;

以下是 src/components/MottoList/mottoListStyles.js,我們使用到了 Dimensions,可以根據螢幕的高度來設定整個 ListView 高度:

import { StyleSheet, Dimensions } from 'react-native';
const { height } = Dimensions.get('window');
export default StyleSheet.create({
  listView: {
    flex: 1,
    flexDirection: 'column',
    height: height - 105,
  },
});

以下是 src/components/ListItem/ListItem.js,我們從 props 收到了上層傳進來的 motto item,顯示出 motto 文字內容。當我們點擊 <TouchableHighlight> 時就會刪除該 motto。

import React from 'react';
import ReactNative from 'react-native';
import styles from './listItemStyles';
const { View, Text, TouchableHighlight } = ReactNative;

const ListItem = (props) => {
  return (
    <View style={styles.listItemContainer}>
      <Text style={styles.listItemText}>{props.item.get('text')}</Text>
      <TouchableHighlight onPress={props.onDeleteMotto(props.item.get('id'), props.itemsRef)}>
        <Text>Delete</Text>
      </TouchableHighlight>
    </View>
  )
};

export default ListItem;

以下是 src/components/ListItem/listItemStyles.js

import { StyleSheet } from 'react-native';

export default StyleSheet.create({
  listItemContainer: {
    flex: 1,
    flexDirection: 'row',
    padding: 10,
    margin: 5,
  },
  listItemText: {
    flex: 10,
    fontSize: 18,
    color: '#212121',
  }
});

以下是 src/components/ActionButton/ActionButton.js,當點擊了按鈕則會觸發 onToggleModal 方法,出現新增 motto 的 modal:

import React from 'react';
import ReactNative from 'react-native';
import styles from './actionButtonStyles';
const { View, Text, Modal, TextInput, TouchableHighlight } = ReactNative;  

const ActionButton = (props) => (
  <TouchableHighlight onPress={props.onToggleModal}>
    <View style={styles.buttonContainer}>
        <Text style={styles.buttonText}>Add Motto</Text>
    </View>
  </TouchableHighlight>
);

export default ActionButton;

以下是 src/components/ActionButton/actionButtonStyles.js

import { StyleSheet } from 'react-native';

export default StyleSheet.create({
  buttonContainer: {
    height: 40,
    justifyContent: 'center',
    alignItems: 'center',
    flexDirection: 'column',
    backgroundColor: '#66bb6a',
  },
  buttonText: {
    fontSize: 20,
    color: '#e8f5e9'
  }
});

以下是 src/components/InputModal/InputModal.js,其主要負責 Modal Component 的設計,當輸入內容會觸發 onChangeMottoText 發出 action,注意的是當按下送出鍵,同時會把 Firebase 的參考 itemsRef 送入 onCreateMotto 中,方便透過 API 去即時新增到 Firebase Database,並更新 client state 和重新渲染了 View:

import React from 'react';
import ReactNative from 'react-native';
import styles from './inputModelStyles';
const { View, Text, Modal, TextInput, TouchableHighlight } = ReactNative;
const InputModal = (props) => (
  <View>
    <Modal
      animationType={"slide"}
      transparent={false}
      visible={props.isModalVisible}
      onRequestClose={props.onToggleModal}
      >
     <View>
      <View>
        <Text style={styles.modalHeader}>Please Keyin your Motto!</Text>
        <TextInput
          onChangeText={props.onChangeMottoText}
        />
        <View style={styles.buttonContainer}>      
          <TouchableHighlight 
            onPress={props.onToggleModal}
            style={[styles.cancelButton]}
          >
            <Text
              style={styles.buttonText}
            >
              Cancel
            </Text>
          </TouchableHighlight>
          <TouchableHighlight 
            onPress={props.onCreateMotto(props.itemsRef)}
            style={[styles.submitButton]}
          >
            <Text
              style={styles.buttonText}
            >
              Submit
            </Text>
          </TouchableHighlight>  
        </View>
      </View>
     </View>
    </Modal>
  </View>
);

export default InputModal;

以下是 src/components/InputModal/inputModalStyles.js

import { StyleSheet } from 'react-native';

export default StyleSheet.create({
  modalHeader: {
    flex: 1,
    height: 30,
    padding: 10,
    flexDirection: 'row',
    backgroundColor: '#ffc107',
    fontSize: 20,
  },
  buttonContainer: {
    flex: 1,
    flexDirection: 'row',
  },
  button: {
    borderRadius: 5,
  },
  cancelButton: {
    flex: 1,
    height: 40,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#eceff1',
    margin: 5,
  },
  submitButton: {
    flex: 1,
    height: 40,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#4fc3f7',
    margin: 5,
  },
  buttonText: {
    fontSize: 20,
  }
});

設定完了 Component,我們來探討一下 Container 的部份。以下是 src/containers/ActionButtonContainer/ActionButtonContainer.js

import { connect } from 'react-redux';
import ActionButton from '../../components/ActionButton';
import {
  toggleModal,
} from '../../actions';
 
export default connect(
  (state) => ({}),
  (dispatch) => ({
    onToggleModal: () => (
      dispatch(toggleModal())
    )
  })
)(ActionButton);

以下是 src/containers/InputModalContainer/InputModalContainer.js

import { connect } from 'react-redux';
import InputModal from '../../components/InputModal';
import Immutable from 'immutable';

import {
  toggleModal,
  setInMotto,
  createMotto,
} from '../../actions';
import uuid from 'uuid';
 
export default connect(
  (state) => ({
    isModalVisible: state.getIn(['ui', 'isModalVisible']),
    motto: state.getIn(['motto', 'motto']),
  }),
  (dispatch) => ({
    onToggleModal: () => (
      dispatch(toggleModal())
    ),
    onChangeMottoText: (text) => (
      dispatch(setInMotto({ path: ['motto', 'text'], value: text }))
    ),
    // 新增 motto 是透過 itemsRef 將新增的 motto push 進去,新增後要把本地端的 motto 清空,並關閉 modal:
    onCreateMotto: (motto) => (itemsRef) => () => {
      itemsRef.push({ id: uuid.v4(), text: motto.get('text'), updatedAt: Date.now() });
      dispatch(setInMotto({ path: ['motto'], value: Immutable.fromJS({ id: '', text: '', updatedAt: '' })}));
      dispatch(toggleModal());
    }
  }),
  (stateToProps, dispatchToProps, ownProps) => {
    const { motto } = stateToProps;
    const { onCreateMotto } = dispatchToProps;
    return Object.assign({}, stateToProps, dispatchToProps, ownProps, {
      onCreateMotto: onCreateMotto(motto),
    });
  },
)(InputModal);

以下是 src/containers/MottoListContainer/MottoListContainer.js

import { connect } from 'react-redux';
import MottoList from '../../components/MottoList';
import Immutable from 'immutable';
import uuid from 'uuid';

import {
  createMotto,
  getMottos,
  changeMottoTitle,
} from '../../actions';

export default connect(
  (state) => ({
    mottos: state.getIn(['motto', 'mottos']),
  }),
  (dispatch) => ({
    onCreateMotto: () => (
      dispatch(createMotto())
    ),
    onGetMottos: (mottos) => (
      dispatch(getMottos({ mottos }))
    ),
    onChangeMottoTitle: (title) => (
      dispatch(changeMottoTitle({ value: title }))
    ),
    // 判斷點擊的是哪一個 item 取出其 key,透過 itemsRef 將其移除
    onDeleteMotto: (mottos) => (id, itemsRef) => () => {
      mottos.forEach((value, key) => {
        if(value.get('id') === id) {
          itemsRef.child(key).remove();
        }
      });
    }
  }),
  (stateToProps, dispatchToProps, ownProps) => {
    const { mottos } = stateToProps;
    const { onDeleteMotto } = dispatchToProps;
    return Object.assign({}, stateToProps, dispatchToProps, ownProps, {
      onDeleteMotto: onDeleteMotto(mottos),
    });
  }
)(MottoList);

最後我們可以透過啟動模擬器後使用以下指令開啟我們 App!

$ react-native run-android

最後的成果:

用 React Native + Firebase 開發跨平台行動應用程式

同時你可以在 Firebase 後台進行觀察,當呼叫 Firebase API 進行資料更動時,Firebase Realtime Database 就會即時更新:

用 React Native + Firebase 開發跨平台行動應用程式

2.6 總結

恭喜你!你已經完成了你的第一個 React Native App,若你希望將你開發的應用程式簽章後上架,請參考官方的說明文件,當你完成簽章打包等流程後,我們可以獲得 .apk 檔,這時就可以上架到市集讓隔壁班心儀的女生,啊不是,是廣大的 Android 使用者使用你的 App 啦!當然,由於我們的程式碼可以 100% 共用於 iOS 和 Android 端,所以你也可以同步上架到 Apple Store!

2.7 延伸閱讀

  1. React Native 官方網站
  2. React 官方網站
  3. Redux 官方文件
  4. Ionic Framework vs React Native
  5. How to Build a Todo App Using React, Redux, and Immutable.js
  6. Your First Immutable React & Redux App
  7. React, Redux and Immutable.js: Ingredients for Efficient Web Applications
  8. Full-Stack Redux Tutorial
  9. redux与immutable实例
  10. gajus/redux-immutable
  11. acdlite/redux-actions
  12. Flux Standard Action
  13. React Native ImmutableJS ListView Example
  14. React Native 0.23.1 warning: ‘In next release empty section headers will be rendered’
  15. js.coach
  16. React Native Package Manager
  17. React Native 学习笔记
  18. The beginners guide to React Native and Firebase
  19. Authentication in React Native with Firebase
  20. bruz/react-native-redux-groceries
  21. Building a Simple ToDo App With React Native and Firebase
  22. Firebase Permission Denied
  23. Best Practices: Arrays in Firebase
  24. Avoiding plaintext passwords in gradle
  25. Generating Signed APK

(image via moduscreatecss-tricksteamtreehouseteamtreehousecss-trickscss-tricks)

2.8 🚪 任意門

回首頁 | 上一章:附錄一、React ES5、ES6+ 常見用法對照表 | 下一章:附錄三、React 測試入門教學 |

勘誤、提問或許願 |

3 附錄三、React 測試入門教學

React 測試入門教學

3.1 前言

測試是軟體開發中非常重要的一個環節,本章我們將帶領大家從撰寫最簡單的測試程式碼到整合 Mocha + Chai 官方提供的測試工具和 Airbnb 所設計的 Enzyme 進行 React 測試。

3.2 Mocha 測試初體驗

Mocha 是目前頗為流行的 JavaScript 測試框架之一,其可以很方便使用於瀏覽器端和 Node 環境。

Mocha is a feature-rich JavaScript test framework running on Node.js and in the browser, making asynchronous testing simple and fun. Mocha tests run serially, allowing for flexible and accurate reporting, while mapping uncaught exceptions to the correct test cases.

除了 Mocha 外,尚有許多 JavaScript 單元測試工具可以選擇,例如:JasmineKarma 等。但本章我們主要使用 Mocha + Chai 結合 React 官方測試工具和 Enzyme 進行講解。

在這邊我們先介紹一些比較常用的 Mocha 使用方法,讓大家熟悉測試的用法(若是已經熟悉撰寫測試程式碼的讀者這部份可以跳過):

  1. 安裝環境與套件

    安裝 reactreact-dom

    $ npm install --save react react-dom

    可以在全域安裝 mocha:

    $ npm install --global mocha

    也可以在開發環境下本地端安裝(同時安裝了 babel、eslint、webpack 等相關套件,其中以 mocha、chai、babel 為主要必須):

    $ npm install --save-dev babel-core babel-loader babel-eslint babel-preset-react babel-preset-es2015 eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react webpack webpack-dev-server html-webpack-plugin chai mocha
  2. 測試程式碼
    1. describe(test suite):表示一組相關的測試。describe 為一個函數,第一個參數為 test suite的名稱,第二個參數為實際執行的函數。
    2. it(test case):表示一個單獨測試,為測試裡最小單位。it 為一個函數,第一個參數為 test case 的描述名稱,第二個參數為實際執行的函數。

    在測試程式碼中會包含一個或多個 test suite,而每個 test suite 則會包含一個或多個 test case

  3. 整合 assertion 函式庫 Chai

    所謂的 assertion(斷言),就是判斷程式碼的執行成果是否和預期一樣,若是不一致則會發生錯誤。通常一個 test case 會擁有一個或多個 assertion。由於 Mocha 本身是一個測試框架,但不包含 assertion,所以我們使用 Chai 這個適用於瀏覽器端和 Node 端的 BDD / TDD assertion library。在 Chai 中共提供三種操作 assertion 介面風格:Expect、Assert、Should,在這邊我們選擇使用比較接近自然語言的 Expect。

    基本上,expect assertion 的寫法都是類似:開頭為 expect 方法 + toto.be + 結尾 assertion 方法(例如:equal、a/an、ok、match)

  4. Mocha 基本用法

    mocha 若沒指定要執行哪個檔案,預設會執行 test 資料夾下第一層的測試程式碼。若要讓 test 資料夾中的子資料夾測試碼也執行則要加上 --recursive 參數。

    包含子資料夾:

    $ mocha --recursive

    指定一個檔案

    $ mocha file1.js 

    也可以指定多個檔案

    $ mocha file1.js file2.js

    現在,我們來撰寫一個簡單的測試程式,親身感受一下測試的感覺。以下是 react-mocha-test-example/src/modules/add.js,一個加法的函數:

    const add = (x, y) => (
      x + y
    );
    
    export default add;

    接著我們撰寫測試這個函數的程式碼,測試是否正確。以下是 react-mocha-test-example/src/test/add.test.js

    // test add.js
    import add from '../src/modules/add';
    import { expect } from 'chai';
    
    // describe is test suite, it is test case
    describe('test add function', () => (
      it('1 + 1 = 2', () => (
        expect(add(1, 1)).to.be.equal(2)
      ))
    ));

    在開始執行 mocha 後由於我們使用了,ES6 的語法所以必須使用 bable 進行轉譯,否則會出現類似以下的錯誤:

    import add from '../src/modules/add';
    ^^^^^^

    我們先行設定 .bablerc,我們在之前已經有安裝 babel 相關套件和 presets 所以就會將 ES2015 語法轉譯。

    {
        "presets": [
        "es2015",
        "react",
        ],
        "plugins": []
    }

    此時,我們更改 package.json 中的 scripts,這樣方便每次測試執行:

    若是使用本地端:

    $ ./node_modules/mocha/bin/mocha --compilers js:babel-core/register

    若是使用全域:

    $ mocha --compilers js:babel-core/register

    若是一切順利,我們就可以看到執行測試成功的結果:

    $ mocha add.test.js
    
      test add function
        ✓ 1 + 1 = 2
    
    
      1 passing (181ms)
  5. Mocha 指令參數

    在 Mocha 中有許多可以使用的好用參數,例如:--recursive 可以執行執行測試資料夾下的子資料夾程式碼、--reporter 格式 更改測試報告格式(預設是 spec,也可以更改為 tap)、--watch 用來監控測試程式碼,當有測試程式碼更新就會重新執行、--grep 擷取符合條件的 test case。

    以上這些參數我們可以都整理在 test 資料夾下的 mocha.opts 檔案中當作設定資料,此時再次執行 npm run test 就會把參數也使用進去。

    --watch
    --reporter spec
  6. 非同步測試

    在上面我們討論的主要是同步的狀況,但實際上在開發應用時往往會遇到非同步的情形。而在 Mocha 中每個 test case 最多允許執行 2000 毫秒,當時間超過就會顯示錯誤。為了解決這個問題我們可以在 package.json 中更改:"test": "mocha -t 5000 --compilers js:babel-core/register" 檔案。

    為了模擬測試非同步的情境,所以我們必須先安裝 axios

    $ npm install --save axios

    以下是 react-mocha-test-example/src/test/async.test.js

    import axios from 'axios';
    import { expect } from 'chai';
    
    it('asynchronous return an object', function(done){
      axios
        .get('https://api.github.com/users/torvus')
        .then(function (response) {
          expect(response).to.be.an('object');
          done();
        })
        .catch(function (error) {
          console.log(error);
        });
    });

    由於測試環境是在 Node 中,所以我們必須先安裝 node-fetch 來展現 promise 的情境。

    $ npm install --save node-fetch 

    以下是 react-mocha-test-example/src/test/promise.test.js

    import fetch from 'node-fetch';
    import { expect } from 'chai';
    
    it('asynchronous fetch promise', function() {
      return fetch('https://api.github.com/users/torvus')
        .then(function(response) { return response.json() })
        .then(function(json) { 
          expect(json).to.be.an('object');
        });
    });
  7. 測試使用的 hook

    在 Mocha 中的 test suite 中,有 before()、after()、beforeEach() 和 afterEach() 四種 hook,可以讓你設計在特定時間點執行測試。

    describe('hooks', function() {
      before(function() {
        // 在 before 中的 test case 會在所有 test cases 前執行
      });
      after(function() {
        // 在 after 中的 test case 會在所有 test cases 後執行
      });
      beforeEach(function() {
        // 在 beforeEach 中的 test case 會在每個 test cases 前執行
      });
      afterEach(function() {
        // 在 afterEach 中的 test case 會在每個 test cases 後執行
      });
      // test cases
    });

3.3 動手實作

在上面我們已經先講解了 Mocha + Chai 測試工具和基礎的測試寫法。現在接著我們要來探討 React 中的測試用法。然而,要在 React 中測試 Component 以及 JSX 語法時,使用傳統的測試工具並不方便,所以要整合 Mocha + Chai 官方提供的測試工具和 Airbnb 所設計的 Enzyme(由於官方的測試工具使用起來不太方便所以有第三方針對其進行封裝)進行測試。

3.3.1 使用官方測試工具

我們知道在 React 一個重要的特色為 Virtual DOM 所以在官方的測試工具中有提供測試 Virtual DOM 的方法:Shallow Rendering(createRenderer),以及測試真實 DOM 的方法:DOM Rendering(renderIntoDocument)。

  1. Shallow Rendering(createRenderer)

    Shallow Rendering 係指將一個 Virtual DOM 渲染成子 Component,但是只渲染第一層,不渲染所有子元件,因此處理速度快且不需要 DOM 環境。Shallow rendering 在單元測試非常有用,由於只測試一個特定的 component,而重要的不是它的 children。這也意味著改變一個 child component 不會影響 parent component 的測試。

    以下是 react-addons-test-utils-example/src/test/shallowRender.test.js

    import React from 'react';
    import TestUtils from 'react-addons-test-utils';
    import { expect } from 'chai';
    import Main from '../src/components/Main';
    
    function shallowRender(Component) {
      const renderer = TestUtils.createRenderer();
      renderer.render(<Component/>);
      return renderer.getRenderOutput();
    }
    
    describe('Shallow Rendering', function () {
      it('Main title should be h1', function () {
        const todoItem = shallowRender(Main);
        expect(todoItem.props.children[0].type).to.equal('h1');
        expect(todoItem.props.children[0].props.children).to.equal('Todos');
      });
    });

    以下是 react-addons-test-utils-example/src/test/shallowRenderProps.test.js

    import React from 'react';
    import TestUtils from 'react-addons-test-utils';
    import { expect } from 'chai';
    import TodoList from '../src/components/TodoList';
    
    const shallowRender = (Component, props) => {
      const renderer = TestUtils.createRenderer();
      renderer.render(<Component {...props}/>);
      return renderer.getRenderOutput();
    }
    
    describe('Shallow Props Rendering', () => {
      it('TodoList props check', () => {
        const todos = [{ id: 0, text: 'reading'}, { id: 1, text: 'coding'}];
        const todoList = shallowRender(TodoList, {todos: todos});
        expect(todoList.props.children.type).to.equal('ul');
        expect(todoList.props.children.props.children[0].props.children).to.equal('reading');
        expect(todoList.props.children.props.children[1].props.children).to.equal('coding');
      });
    });
  2. DOM Rendering(renderIntoDocument)

    注意,因為 Mocha 運行在 Node 環境中,所以你不會存取到 DOM。所以我們要使用 JSDOM 來模擬真實 DOM 環境。同時我在這邊引入 react-dom,這樣我們就可以使用 findDOMNode 來選取元素。事實上,findDOMNode 方法的最大優勢是提供比 TestUtils 更好的 CSS 選擇器,方便開發者選擇元素。

    以下是 react-addons-test-utils-example/src/test/setup.test.js

    import jsdom from 'jsdom';
    
    if (typeof document === 'undefined') {
      global.document = jsdom.jsdom('<!doctype html><html><head></head><body></body></html>');
      global.window = document.defaultView;
      global.navigator = global.window.navigator;
    }

    以下是 react-addons-test-utils-example/src/components/TodoHeader/TodoHeader.js

    import React from 'react';
    
    class TodoHeader extends React.Component {
      constructor(props) {
        super(props);
        this.toggleButton = this.toggleButton.bind(this);
        this.state = {
          isActivated: false,
        };
      }
      toggleButton() {
        this.setState({
          isActivated: !this.state.isActivated,      
        })
      }
      render() {
        return (
          <div>
            <button disabled={this.state.isActivated} onClick={this.toggleButton}>Add</button>
          </div>
        );
      };
    }
    
    export default TodoHeader;

    需要留意的是若是 stateless components 使用 TestUtils.renderIntoDocument,要將 renderIntoDocument 包在 <div></div> 內,使用 findDOMNode(TodoHeaderApp).children[0] 取得,不然會回傳 null。更進一步細節可以參考這裡。不過由於我們是使用 class-based Component 所以不會遇到這個問題。

    以下是 react-addons-test-utils-example/src/test/renderIntoDocument.test.js

    import React from 'react';
    import TestUtils from 'react-addons-test-utils';
    import { expect } from 'chai';
    import { findDOMNode } from 'react-dom';
    import TodoHeader from '../src/components/TodoHeader';
    
    describe('Simulate Event', function () {
      it('When click the button, it will be toggle', function () {
        const TodoHeaderApp = TestUtils.renderIntoDocument(<TodoHeader />);
        const TodoHeaderDOM = findDOMNode(TodoHeaderApp);
        const button = TodoHeaderDOM.querySelector('button');
        TestUtils.Simulate.click(button);
        let todoHeaderButtonAfterClick = TodoHeaderDOM.querySelector('button').disabled;
        expect(todoHeaderButtonAfterClick).to.equal(true);
      });
    });

    這種渲染 DOM 的測試方式類似於 JavaScript 或 jQuery 的 DOM 操作。首先要先找到欲操作的目標節點,而後觸發想要執行的動作,在官方測試工具中擁有許多可以協助選取節點的方法。然而由於其在使用上不夠簡潔,也因此我們接下來將介紹由 Airbnb 所設計的 Enzyme進行 React 測試。

3.3.2 使用 Enzyme 函式庫進行測試

Enzyme 優勢是在於針對官方測試工具封裝成了類似 jQuery API 的選取元素的方式。根據官方網站介紹 Enzyme 將更容易地去操作選取 React Component:

Enzyme is a JavaScript Testing utility for React that makes it easier to assert, manipulate, and traverse your React Components’ output.
Enzyme is unopinionated regarding which test runner or assertion library you use, and should be compatible with all major test runners and assertion libraries out there.

在 Enzyme 中選取元素使用 find()

component.find('.className'); // 使用 class 選取
component.find('#idName'); // 使用 id 選取
component.find('h1'); // 使用元素選取

接下來我們介紹 Enzyme 三個主要的 API 方法:

  1. Shallow Rendering

    shallow 方法事實上就是官方測試工具的 shallow rendering 封装。同樣是只渲染第一層,不渲染所有子元件。

    import React from 'react';
    import TestUtils from 'react-addons-test-utils';
    import { expect } from 'chai';
    import { shallow } from 'enzyme';
    import Main from '../../src/components/Main';
    
    describe('Enzyme Shallow Rendering', () => {
      it('Main title should be Todos', () => {
        const main = shallow(<Main />);
        // 判斷 h1 文字是否如預期
        expect(main.find('h1').text()).to.equal('Todos');
      });
    });
  2. Static Rendering

    render 方法是將 React 元件渲染成靜態的 HTML 字串,並利用 Cheerio 函式庫(這點和 shallow 不同)分析其結構返回物件。雖然底層是不同的處理引擎但使用上 API 封裝起來和 Shallow 卻是一致的。需要注意的是 Static Rendering 非只渲染一層,需要注意是否需要 mock props 傳遞。

    import React from 'react';
    import TestUtils from 'react-addons-test-utils';
    import { expect } from 'chai';
    import { render } from 'enzyme';
    import Main from '../../src/components/Main';
    
    describe('Enzyme Staic Rendering', () => {
      it('Main title should be Todos', () => {
        const todos = [{ id: 0, text: 'reading'}, { id: 1, text: 'coding'}];
        const main = render(<Main todos={todos} />);
        expect(main.find('h1').text()).to.equal('Todos');
      });
    });
  3. Full Rendering

    mount 方法 React 元件載入真實 DOM 節點。同樣因為牽涉到 DOM 也要使用 JSDOM。

    import React from 'react';
    import TestUtils from 'react-addons-test-utils';
    import { expect } from 'chai';
    import { findDOMNode } from 'react-dom';
    import { mount } from 'enzyme';
    import TodoHeader from '../../src/components/TodoHeader';
    
    describe('Enzyme Mount', () => {
      it('Click Button', () => {
        let todoHeaderDOM = mount(<TodoHeader />);
        // 取得 button 並模擬 click
        let button = todoHeaderDOM.find('button').at(0);
        button.simulate('click');
        // 檢查 prop(key) 是否正確
        expect(button.prop('disabled')).to.equal(true);
      });
    });

最後我們可以在 react-addons-test-utils-example 資料夾下執行:

$ npm test

若一切順利就可以看到測試通過的訊息!


  Enzyme Mount
    ✓ Click Button (44ms)

  Enzyme Shallow Rendering
    ✓ Main title should be Todos

  Enzyme Staic Rendering
    ✓ Main title should be Todos

  Simulate Event
    ✓ When click the button, it will be toggle

  Shallow Rendering
    ✓ Main title should be h1

  Shallow Props Rendering
    ✓ TodoList props check


  6 passing (279ms)

事實上 Enzyme 還提供更多的 API 可以使用,若是讀者想了解更多 Enzyme API 可以 參考官方文件

3.4 總結

以上我們從 Mocha + Chai 的使用方式介紹到 React 官方提供的測試工具 和 Airbnb 所設計的 Enzyme,相信讀者對於測試程式碼已經有初步的了解,若尚未掌握的讀者不妨跟著上面的範例再重新走過一遍,接著我們要進到最後的 GraphQL/Relay的介紹。

3.5 延伸閱讀

  1. React 测试入门教程
  2. 测试框架 Mocha 实例教程
  3. Test Utilities
  4. JavaScript Testing utilities for React
  5. 持续集成是什么?
  6. Let’s test React components with TDD, Mocha, Chai, and jsdom
  7. Unit Testing React-Native Components with Enzyme Part 1
  8. What React Stateless Components Are Missing
  9. 0.14-rc1: findDOMNode(statelessComponent) doesn’t work with TestUtils.renderIntoDocument #4839
  10. Writing Redux Tests
  11. 【译】展望2016,React.js 最佳实践 (中英对照版)

(image via Anthony Ng

3.6 🚪 任意門

回首頁 | 上一章:附錄二、用 React Native + Firebase 開發跨平台行動應用程式 | 下一章:附錄四、GraphQL/Relay 初體驗 |

勘誤、提問或許願 |

4 附錄四、GraphQL/Relay 初體驗

Relay/GraphQL 初體驗

4.1 前言

GraphQL 的出現主要是為了要解決 Web/Mobile 端不斷增加的 API 請求所衍生的問題。由於 RESTful 最大的功能在於很有效的前後端分離和建立 stateless 請求,然而 RESTful API 的資源設計上比較偏向單方面的互動,若是有著複雜資源間的關聯就會出現請求次數過多,遇到不少的瓶頸。

4.2 GraphQL 初體驗

GraphQL is a data query language and runtime designed and used at Facebook to request and deliver data to mobile and web apps since 2012.

根據 GraphQL 官方網站的定義,GraphQL 是一個資料查詢語言和 runtime。Query responses 是由 client 所宣告決定,而非 server 端,且只會回傳 client 所宣告的內容。此外,GraphQL 是強型別(strong type)且可以容易使用階層(hierarchical)和處理複雜的資料關連性,並更容易讓前端工程師和產品工程師定義 Schema 來使用,賦予前端對於資料的制定能力。

GraphQL 主要由以下元件構成:

  1. 類別系統(Type System)
  2. 查詢語言(Query Language):在 Operations 中 query 只讀取資料而 mutation 寫入操作
  3. 執行語意(Execution Semantics)
  4. 靜態驗證(Static Validation)
  5. 類別檢查(Type Introspection)

一般 RESTful 在取用資源時會對應到 HTTP 中 GETPOSTDELETEPUT 等方法,並以 URL 對應的方式去取得資源,例如:

取得 id 為 3500401 的使用者資料:

GET /users/3500401

以下則是 GraphQL 宣告的 query 範例,宣告式(declarative)的方式比起 RESTful 感覺起來相對直觀:

{
  user(id: 3500401) {
    id,
    name,
    isViewerFriend,
    profilePicture(size: 50)  {
      uri,
      width,
      height
    }
  }
}

接收到 GraphQL query 後 server 回傳結果:

{
  "user" : {
    "id": 3500401,
    "name": "Jing Chen",
    "isViewerFriend": true,
    "profilePicture": {
      "uri": "http://someurl.cdn/pic.jpg",
      "width": 50,
      "height": 50
    }
  }
}

4.2.1 實戰演練

在 GraphQL 中有取得資料 Query、更改資料 Mutation 等操作。以下我們先介紹如何建立 GraphQL Server 並取得資料。

  1. 環境建置
    接下來我們將動手建立 GraphQL 的簡單範例,讓大家感受一下 GraphQL 的特性,在這之前我們需要先安裝以下套件建立好環境:

    1. graphql:GraphQL 的 JavaScript 實作.
    2. express:Node web framework.
    3. express-graphql, an express middleware that exposes a GraphQL server.
    $ npm init
    $ npm install graphql express express-graphql --save
  2. Data 格式設計

    以下是 data.json

    {
      "1": {
        "id": "1",
        "name": "Dan"
      },
      "2": {
        "id": "2",
        "name": "Marie"
      },
      "3": {
        "id": "3",
        "name": "Jessie"
      }
    }
  3. Server 設計

    // 引入函式庫
    import graphql from 'graphql';
    import graphqlHTTP from 'express-graphql';
    import express from 'express';
    
    // 引入 data
    const data = require('./data.json');
    
    // 定義 User type 的兩個子 fields:`id` 和 `name` 字串,注意型別對於 GraphQL 非常重要
    const userType = new graphql.GraphQLObjectType({
      name: 'User',
      fields: {
        id: { type: graphql.GraphQLString },
        name: { type: graphql.GraphQLString },
      }
    });
    
    const schema = new graphql.GraphQLSchema({
      query: new graphql.GraphQLObjectType({
        name: 'Query',
        fields: {
          user: {
            // 使用上面定義的 userType
            type: userType,
            // 定義所接受的 user 參數
            args: {
              id: { type: graphql.GraphQLString }
            },
            // 當傳入參數後 resolve 如何處理回傳 data
            resolve: function (_, args) {
              return data[args.id];
            }
          }
        }
      })
    });
    
    // 啟動 graphql server
    express()
      .use('/graphql', graphqlHTTP({ schema: schema, pretty: true }))
      .listen(3000);
    
    console.log('GraphQL server running on http://localhost:3000/graphql');

    在終端機執行:

    node index.js

    這個時候我們可以打開瀏覽器輸入 localhost:3000/graphql.,由於沒有任何 Query,目前會出現以下畫面:

    Relay/GraphQL 初體驗

  4. Query 設計

    當 GraphQL 指令為:

    {
      user(id: "1") {
        name
      }
    }

    將回傳資料:

    {
      "data": {
        "user": {
          "name": "Dan"
        }
      }
    }

    在了解了資料和 Query 設計後,這個時候我們可以打開瀏覽器輸入(當然也可以透過終端機 curl 的方式執行):
    http://localhost:3000/graphql?query={user(id:"1"){name}},此時 server 會根據 GET 的資料回傳:

    Relay/GraphQL 初體驗

到這裡,你已經完成了最簡單的 GraphQL Server 設計了,若你遇到編碼問題,可以嘗試使用 JavaScript 中的 encodeURI 去進行轉碼。也可以自己嘗試不同的 Schema 和 Query,感受一下 GraphQL 的特性。事實上,GraphQL 還擁有許多有趣的特色,例如:Fragment、指令、Promise 等,若讀者對於 GraphQL 有興趣可以進一步參考 GraphQL 官網

4.3 Relay 初體驗

Relay is a new framework from Facebook that provides data-fetching functionality for React applications.

在體驗完 GraphQL 後,我們要來聊聊 Relay。Relay 是 Facebook 為了滿足大型應用程式開發所建構的框架,主要用於處理 React 應用層(Application)的資料互動框架。在 Relay 中可以讓每個 Component 透過 GraphQL 的整合處理可以精確地向 Component props 提供取得的數據,並在 client side 存放一份所有數據的 store 當作暫存。

整個 Relay 架構流程圖:

Relay/GraphQL 初體驗

一般來說要使用 Relay 必須先準備好以下三項工具:

  1. A GraphQL Schema
  2. A GraphQL Server
  3. Relay
    • network layer:Relay 透過 network layer 傳 GraphQL 給 server

接下來我們來透過 React 官方上的範例來讓大家感受一下 Relay 的特性。上面我們有提過:在 Relay 中可以讓每個 Component 透過 GraphQL 的整合處理可以更精確地向 Component props 提供取得的數據,並在 client side 存放一份所有數據的 store 當作暫存。所以,首先我們先建立每個 Component 和 GraphQL/Relay 的對應:

// 建立 Tea Component,從 this.props.tea 取得資料
class Tea extends React.Component {
  render() {
    var {name, steepingTime} = this.props.tea;
    return (
      <li key={name}>
        {name} (<em>{steepingTime} min</em>)
      </li>
    );
  }
}
// 使用 Relay.createContainer 建立資料溝通窗口 
Tea = Relay.createContainer(Tea, {
  fragments: {
    tea: () => Relay.QL`
      fragment on Tea {
        name,
        steepingTime,
      }
    `,
  },
});

class TeaStore extends React.Component {
  render() {
    return <ul>
      {this.props.store.teas.map(
        tea => <Tea tea={tea} />
      )}
    </ul>;
  }
}
TeaStore = Relay.createContainer(TeaStore, {
  fragments: {
    store: () => Relay.QL`
      fragment on Store {
        teas { ${Tea.getFragment('tea')} },
      }
    `,
  },
});

// Route 設計
class TeaHomeRoute extends Relay.Route {
  static routeName = 'Home';
  static queries = {
    store: (Component) => Relay.QL`
      query TeaStoreQuery {
        store { ${Component.getFragment('store')} },
      }
    `,
  };
}

ReactDOM.render(
  <Relay.RootContainer
    Component={TeaStore}
    route={new TeaHomeRoute()}
  />,
  mountNode
);

GraphQL Schema 和 store 建立:

// 引入函式庫
import {
  GraphQLInt,
  GraphQLList,
  GraphQLObjectType,
  GraphQLSchema,
  GraphQLString,
} from 'graphql';

// client side 暫存 store,GraphQL Server reponse 會更新 store,再透過 props 傳遞給 Component
const STORE = {
  teas: [
    {name: 'Earl Grey Blue Star', steepingTime: 5},
    {name: 'Milk Oolong', steepingTime: 3},
    {name: 'Gunpowder Golden Temple', steepingTime: 3},
    {name: 'Assam Hatimara', steepingTime: 5},
    {name: 'Bancha', steepingTime: 2},
    {name: 'Ceylon New Vithanakande', steepingTime: 5},
    {name: 'Golden Tip Yunnan', steepingTime: 5},
    {name: 'Jasmine Phoenix Pearls', steepingTime: 3},
    {name: 'Kenya Milima', steepingTime: 5},
    {name: 'Pu Erh First Grade', steepingTime: 4},
    {name: 'Sencha Makoto', steepingTime: 2},
  ],
};

// 設計 GraphQL Type
var TeaType = new GraphQLObjectType({
  name: 'Tea',
  fields: () => ({
    name: {type: GraphQLString},
    steepingTime: {type: GraphQLInt},
  }),
});

// 將 Tea 整合進來
var StoreType = new GraphQLObjectType({
  name: 'Store',
  fields: () => ({
    teas: {type: new GraphQLList(TeaType)},
  }),
});

// 輸出 GraphQL Schema
export default new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'Query',
    fields: () => ({
      store: {
        type: StoreType,
        resolve: () => STORE,
      },
    }),
  }),
});

限於篇幅,我們只能讓大家感受一下 Relay 的簡單範例,若大家想進一步體驗 Relay 的優勢,已經幫你準備好 GraphQL Server、transpiler 的 Relay Starter Kit 專案會是個很好的開始。

4.4 總結

React 生態系中,除了前端 View 的部份有革新性的創新外,GraphQL 更是對於資料取得的全新思路。雖然 GraphQL 和 Relay 已經成為開源專案,但技術上仍持續演進,若需要在團隊 production 上導入仍可以持續觀察。到這邊,若是一路從第一章看到這裡的讀者真的要給自己一個熱烈掌聲了,我知道對於初學者來說 React 龐大且有許多的新的觀念需要消化,但如同筆者在最初時所提到的,學習 React 重要的是透過這個生態系去學習現代化網頁開發的工具和方法以及思路,成為更好的開發者。根據前端摩爾定律,每半年就有一次大變革,但基本 Web 問題和觀念依然不變,大家一起加油啦!若有任何問題都歡迎來信給筆者或是發 issue,當然 PR is welcome :)

4.5 延伸閱讀

  1. Your First GraphQL Server
  2. 搭建你的第一个 GraphQL 服务器
  3. Learn GraphQL
  4. GraphQL vs Relay
  5. GraphQL 官網
  6. Relay 官網
  7. A reference implementation of GraphQL for JavaScript
  8. 深入理解 GraphQL
  9. Node.js 服务端实践之 GraphQL 初探

(image via facebookkadira

4.6 🚪 任意門

回首頁 | 上一章:附錄三、React 測試入門教學 |

勘誤、提問或許願 |

5 Ch01 前端工程簡介和 React 生態系簡介

  1. Web 前端工程入門簡介
  2. React 生態系入門簡介

5.1 🚪 任意門

| 回首頁 |

6 Web 前端工程入門簡介

Web 前端工程入門簡介

6.1 前言

隨著現代化網頁(Modern Web)開發專業和複雜性的提昇以及對於使用者體驗的要求下,網頁開發已從過去的 Web Develpoer 一夫當關,轉向專業分工,更加細分成網頁前端(Web Front End)、網頁後端(Web Back End)等職位。此外,由於跨平台、跨瀏覽器的需求日益增加,技術變化更迭快速,市場上對於前端工程師(Web Front End Engineer)的需求也與日俱增,前端工程的(Front End Engineering)所要面對的挑戰也越來越多。

Web 前端工程入門簡介

6.2 前端工程範疇

事實上,在目前的業界,前端工程的定位光譜非常廣泛,有聚焦在網頁設計(Web Design),也有專注在軟體工程(Software Engineering)的部份,本書則是將前端工程定位在軟體工程的範疇。而 HTML、CSS 和 JavaScript 是前端工程最重要的技術基礎。過去一段時間,我們所認為的前端工程主要專注在瀏覽器平台,但現在的 Web 平台已經不再侷限於桌面瀏覽器,而是必須面對更多的跨平台、跨瀏覽器的應用開發場景,其中包含:

  1. 網頁瀏覽器(Web Browser),一般的網頁應用程式開發
  2. 透過 CLI 指令去操作的 Headless 瀏覽器(Headless Application)。例如:phantomJSCasperJS
  3. 運作在 WebView 瀏覽器核心(WebView Application)的應用。例如:Apache CordovaElectronNW.js 等行動、桌面應用程式開發
  4. 原生應用程式(Native Application),透過 Web 技術撰寫原生應用程式。例如:React NativeNative Script

過去幾年,前端開發就像經歷了文藝復興(Rinascimento)的年代,開始了各種框架、套件百花齊放的時代。雖然現在有更多好用工具可以協助開發,但前端工程師似乎並沒有變得比較輕鬆。以往若能妥善運用 jQuery 等函式庫就可以應付大部分前端工程師的工作,但現在前端徵才廣告上不僅要求精通 HTML、CSS 和 JavaScript,還要對於還要對於 BackboneEmberAngularReactVue 等 JavaScript 框架或函式庫有一定程度的了解。

在眾多 JavaScript 框架或函式庫中,React 是 Facebook 推出的開源 JavaScript Library,它的出現讓許多革新性的 Web 觀念開始流行起來,例如:Virtual DOM、Web Component、更直覺的宣告式 UI 設計、更優雅地實現 Server Rendering 等。接下來本書將透過介紹 React 生態系(ecosystem)帶領讀者入門 React 的世界,讓讀者可以從零開始真的動手用 React 開發跨平台應用程式。

(image via bsdacademyfirebase

6.3 🚪 任意門

回首頁 | 下一章:React 生態系(Ecosystem)入門簡介 |

勘誤、提問或許願 |

7 React 生態系(Ecosystem)入門簡介

React 生態系(Ecosystem)入門簡介

根據 React 官方網站 的說明:React 是一個專注於 UI(View)的 JavaScript 函式庫(Library)。自從 Facebook 於 2013 年開源 React 這個函式庫後,相關的生態系開始蓬勃發展。事實上,透過學習 React 生態系(ecosystem)的過程中,可以讓我們順便學習現代化 Web 開發的重要觀念(例如:模組化、ES6+、Webpack、Babel、ESLint、函數式程式設計等),成為更好的開發者。

7.1 ReactJS

ReactJS 是 Facebook 推出的 JavaScript 函式庫,若以 MVC 框架來看,React 定位是在 View 的範疇。在 ReactJS 0.14 版之後,ReactJS 更把原先處理 DOM 的部分獨立出去(react-dom),讓 ReactJS 核心更單純,也更符合 React 所倡導的 Learn once, write everywhere 的理念。事實上,ReactJS 本身的 API 相對單純,但由於整個生態系非常龐大,因此學習 React 卻是一條漫長的道路。此外,當你想把 React 應用在你的應用程式時,你通常必須學習整個 React Stack 才能充分發揮 React 的最大優勢。

7.2 JSX

事實上,JSX 並非一種全新的語言,而是一種語法糖(Syntatic Sugar),一種語法類似 XML 的 ECMAScript 語法擴充。在 JSX 中 HTML 和組建這些元素標籤的程式碼有緊密的關係,這和過去我們強調 HTML、JavaScript 分離的觀念有很大不同。當然,你可以選擇不要在 React 使用 JSX,不過相信我,當你真正開始撰寫 React 元件(Component)時,你會很慶幸有 JSX 真好。

7.3 NPM

NPM(Node Package Manager)是 Node.js 下的主流套件管理工具。在 NPM 上有非常多的套件,可以讓你不用再重造輪子,更可以讓你可以輕鬆用指令管理不同的套件。由於 NPM 主要是基於 CommonJS 的規範,通常必須搭配 Browserify 這樣的工具才能在前端使用 NPM 的模組。然而因 NPM 是基於 Nested Dependency Tree,不同的套件有可能會在引入依賴時會引入相同但不同版本的套件,造成檔案大小過大的情形。這和另一個套件管理工具 Bower 專注在前端套件且使用 Flat Dependency Tree(讓使用者決定相依的套件版本)是比較不同的地方。

7.4 ES6+

ES6+ 係指 ES6(ES2015)和 ES7 的聯集,在 ES6+ 新的標準當中引入許多新的特性和功能,彌補了過去 JavaScript 被詬病的一些特性。由於未來 React 將以支援 ES6+ 為主,因此直接學習 ES6+ 用法是相對好的選擇,本書的所有範例也將會以 ES6+ 撰寫。

7.5 Babel

由於並非所有瀏覽器都支援 ES6+ 語法,所以透過 Babel 這個 JavaScript 編譯器(可以想成是翻譯機或是翻譯蒟篛)可以讓你的 ES6+ 、JSX 等程式碼轉換成瀏覽器可以看得懂的語法。通常會在資料夾的 root 位置加入 .babelrc 進行轉譯規則 preset 和引用外掛(plugin)的設定。

7.6 JavaScript 模組化開發

隨著 Web 應用程式的複雜性提高,JavaScript 模組化開發已經成為必然的趨勢,以下簡單介紹 JavaScript 模組化的相關規範。事實上,在一開始沒有官方定義的標準時出現了各種社群自行定義的規範和實踐。

  1. CDN-Based

    也就是最傳統的 <script> 引入方式,然而使用這種方式雖然簡單方便,但在開發實際中大型應用程式時會產生許多弊端:

    • 全域作用域容易造成變數污染和衝突
    • 文件只能依照 <script> 順序載入,不具彈性
    • 在大型專案中各種資源和版本難以維護
    • 必須由開發者自行判斷模組和函式庫之間的依賴關係
  2. AMD

    Asynchronous Module Definition 簡稱 AMD,為非同步載入模組的規範,其在宣告時模組時即需定義依賴的模組。AMD 常用於瀏覽器端,其最著名的實踐為 RequireJS

    基本格式:

    define(id?, dependencies?, factory);
  3. CommonJS

    CommonJS 規範是一種同步模組載入的規範。以 Node.js 其遵守 CommonJS 規範,使用 require 進行模組同步載入,並透過 exportsmodule.exports 來輸出模組。主要實現為 Node.js 伺服器端的同步載入和瀏覽器端的 Browserify

  4. CMD

    CMD 全稱為 Common Module Definition,其規範和 AMD 類似,但相對簡潔,卻又保持和 CommonJS 的兼容性。其最大特色為:依賴就近,延遲執行。主要實現為:Sea.js

  5. UMD

    Universal Module Definition 是為了要兼容 CommonJS 和 AMD 所設計的規範,希望讓模組能跨平台執行。

  6. ES6 Module

    ECMAScript6 的標準中定義了 JavaScript 的模組化方式,讓 JavaScript 在開發大型複雜應用程式時上更為方便且易於管理,亦可以取代過去 AMD、CommonJS 等規範,成為通用於瀏覽器端和伺服器端的模組化解決方案。但目前瀏覽器和 Node 在 ES6 模組支援度還不完整,大部分情況需要透過 Babel 轉譯器進行轉譯。

7.7 Webpack/Browserify + Gulp

隨著網頁應用程式開發的複雜性提昇,現在的網頁往往不單只是單純的網頁,而是一個網頁應用程式(WebApp)。為了管理複雜的應用程式開發,此時模組化開發方法便顯得日益重要,而理想上的模組化開發工具一直是前端工程的很大的議題。Webpack 和 Browserify + Gulp 則是進行 React 應用程式開發常用的開發工具,可以協助進行自動化程式碼打包、轉譯等重複性工作,提昇開發效率。本書範例主要會搭配 Webpack 進行開發。

  1. Webpack

    Webpack 是一個模組打包工具(module bundler),以下列出 Webpack 的幾項主要功能:
    • 將 CSS、圖片與其他資源打包
    • 打包之前預處理(Less、CoffeeScript、JSX、ES6 等)的檔案
    • 依 entry 文件不同,把 .js 分拆為多個 .js 檔案
    • 整合豐富的 Loader 可以使用(Webpack 本身僅能處理 JavaScript 模組,其餘檔案如:CSS、Image 需要載入不同 Loader 進行處理)
  2. Browserify

    如同官網上說明的:Browserify lets you require('modules') in the browser by bundling up all of your dependencies.,Browserify 是一個可以讓你在瀏覽器端也能使用像 Node 用的 CommonJS 規範一樣,用輸出(export)和引用(require)來管理模組。此外,也能讓前端使用許多在 NPM 中的模組。

  3. Gulp

    Gulp 是一個前端任務工具自動化管理工具(Task Runner)。隨著前端工程的發展,我們在開發前端應用程式時有許多工作是必須重複進行,例如:打包文件、uglify、將 LESS 轉譯成一般的 CSS 的檔案,轉譯 ES6 語法等工作。若是使用一般手動的方式,往往會造成效率的低下,所以透過像是 Grunt、Gulp 這類的 Task Runner 不但可以提昇效率,也可以更方便管理這些任務。由於 Gulp 是透過 pipeline 方式來處理檔案,在使用上比起 Grunt 的方式直觀許多,所以這邊我們主要討論的是 Gulp。

7.8 ESLint

ESLint 是一個提供 JavaScript 和 JSX 的程式碼檢查工具,可以確保團隊的程式碼品質。其支援可插拔的特性,可以根據需求在 .eslintrc 設定檢查規則。目前主流的檢查規則會使用 Airbnb 所釋出的 Airbnb React/JSX Style Guide,在使用上需先安裝 eslint-config-airbnb 等套件。

7.9 React Router

React Router 是 React 中主流使用的 Routing 函式庫,透過 URL 的變化來管理對應的狀態和元件。若開發不刷頁的單頁式(single page application)的 React 應用程式通常都會需要用到。

7.10 Flux/Redux

Flux 是一個實現單項流的應用程式資料架構(architecture),同樣是由 Facebook 推出,並和 React 專注於 View 的部份形成互補。而由 Dan Abramov 所開發的 Redux 被 React 開發社群認為是 Flux-like 更優雅的作法,也是目前主流搭配 React 的狀態(State)管理工具。讓你在開發複雜的應用程式時可以更方便管理你的狀態(state)。

7.11 ImmutableJS

ImmutableJS,是一個能讓開發者建立不可變資料結構的函式庫。建立不可變(immutable)資料結構不僅可以讓狀態可預測性更高,也可以提昇程式的效能。

7.12 Isomorphic JavaScript

Isomorphic JavaScript 是指前後端(Client/Server)共用相同部分的程式碼,讓 JavaScript 應用可以同時執行在瀏覽器端和伺服器端,在 React 中可以透過伺服器端渲染(server side rendering)靜態 HTML 的方式達到 Isomorphic JavaScript 效果,讓 SEO 和執行效能更加提昇並讓前後端共用程式碼。而另一個常一起出現的 Universal JavaScript 一般定義更為廣泛,係指可以運行在不同環境下的 JavaScript Code,並不局限於瀏覽器和伺服器端。但要留意的是在 Github 和許多技術文章的分享上會把兩者定義為同一件事情。

7.13 React 測試

Facebook 本身有提供 Test Utilities,但由於不夠好用,所以目前主流開發社群比較傾向使用 Airbnb 團隊開發的 enzyme,其可以與市面上常見的測試工具(MochaKarma、Jest 等)搭配使用。其中 Jest 是 Facebook 所開發的單元測試工具,其主要基於 Jasmine 所建立的測試框架。Jest 除了支援 JSDOM 外,也可以自動模擬 (mock) 透過 require() 進來的模組,讓開發者可以更專注在目前被測試的模組中。

7.14 React Native

React Native和過去的 Apache Cordova 等基於 WebView 的解決方案比較不同,它讓開發者可以使用 React 和 JavaScript 開發原生應用程式(Native App),讓 Learn once, write anywhere 理想變得可能。

7.15 GraphQL/Relay

GraphQL 是 Facebook 所開發的資料查詢語言(Data Query Language),主要是想解決傳統 RESTful API 所遇到的一些問題,並提供前端更有彈性的 API 設計方式。Relay 則是 Facebook 提出搭配 GraphQL 用於 React 的一個宣告式數據框架,可以降低 Ajax 的請求數量(類似的框架還有 Netflix 推出的 Falcor)。但由於目前主流的後端 API 仍以傳統 RESTful API 設計為主,所以在使用 GraphQL 上通常會需要比較大架構設計的變動。因此本書則是把 GraphQL/Relay 介紹放到附錄的部份,讓有興趣的讀者可以自行參考體驗一下。

7.16 總結

以上就是讀者在 React 生態系遊走時會遇到的各種關卡,也許有些初學者會對於這樣龐大的體系所嚇到,放棄學習 React 這項革新性技術的機會。不過別擔心,接下來筆者將帶領讀者按圖索驥,依序介紹整個 React 生態系的各種技術,一步步帶領大家用 React 實作出生活中會用到的應用程式。

7.17 延伸閱讀

  1. Navigating the React.JS Ecosystem
  2. petehunt/react-howto
  3. React Ecosystem - A summary
  4. React Official Site
  5. A collection of awesome things regarding React ecosystem
  6. Webpack 中文指南
  7. AMD 和 CMD 的区别有哪些?
  8. jslint to eslint
  9. Facebook的Web开发三板斧:React.js、Relay和GraphQL
  10. airbnb/javascript

(image via jpsierens

7.18 🚪 任意門

回首頁 | 上一章:Web 前端工程入門簡介 | 下一章:React 開發環境設置與 Webpack 入門教學 |

勘誤、提問或許願 |

8 Ch02 React 開發環境設置與 Webpack 入門

  1. React 開發環境設置與 Webpack 入門

8.1 🚪 任意門

| 回首頁 |

9 Browserify + Gulp + Babelify

一看就懂的 React 開發環境建置與 Webpack 入門教學

在進入第二種方法前,首先先介紹一下會用到 BrowserifyGulpBabelify 三種前端開發常會用到的工具:

Browserify

  • 如同官網上說明的:Browserify lets you require('modules') in the browser by bundling up all of your dependencies.,Browserify 是一個可以讓你在瀏覽器端也能使用像 Node 用的 CommonJS 規範一樣,用輸出(export)和引用(require)來管理模組。此外,也能使用許多在 NPM 中的模組

Gulp

  • Gulp 是一個前端任務工具自動化管理工具。隨著前端工程的發展(Task Runner),我們在開發前端應用程式時有許多工作是必須重複進行,例如:打包文件、uglify、將 LESS 轉譯成一般的 CSS 的檔案,轉譯 ES6 語法等工作。若是使用一般手動的方式,往往會造成效率的低下,所以透過像是 Grunt、Gulp 這類的 Task Runner 不但可以提昇效率,也可以更方便管理這些任務。由於 Gulp 是透過 pipeline 方式來處理檔案,在使用上比起 Grunt 的方式直觀許多,所以這邊我們主要討論的是 Gulp

Babelify

  • Babelify 是一個使用 Browserify 進行 Babel 轉換的外掛,你可以想成是一個翻譯機,可以將 React 中的 JSXES6 語法轉成瀏覽器相容的 ES5 語法

初步了解了三種工具的概念後,接下來我們就開始我們的環境設置:

  1. 若是電腦中尚未安裝 Node(Node.js 是一個開放原始碼、跨平台的、可用於伺服器端和網路應用的 Google V8 引擎執行執行環境)和 NPM(Node 套件管理器 Node Package Manager。是一個以 JavaScript 編寫的軟體套件管理系統,預設環境為 Node.js,從 Node.js 0.6.3 版本開始,npm 被自動附帶在安裝包中)的話,請先 上官網安裝

  2. npm 安裝 browserify

  3. npm 安裝 gulpgulp-concatgulp-html-replacegulp-streamifygulp-uglifywatchifyvinyl-source-stream 開發環境用的套件(development dependencies)

    // 使用 npm install --save-dev 會將安裝的套件名稱和版本存放到 package.json 的 devDependencies 欄位中
    $ npm install --save-dev gulp gulp-concat gulp-html-replace gulp-streamify gulp-uglify watchify vinyl-source-stream  
  4. 安裝 babelifybabel-preset-es2015babel-preset-react,轉譯 ES6JSX 開發環境用的套件,並於根目錄底下設定 .babelrc,設定轉譯規則(presets:es2015、react)和使用的外掛

    // 使用 npm install --save-dev 會將安裝的套件名稱和版本存放到 package.json 的 devDependencies 欄位中
    $ npm install --save-dev babelify babel-preset-es2015 babel-preset-react
    // filename: .babelrc
    {
        "presets": [
          "es2015",
          "react",
        ],
        "plugins": []
    }
  5. 安裝 react 和 react-dom

    $ npm install --save react react-dom
  6. 撰寫 Component

    // filename: ./app/index.js
    import React from 'react';
    import ReactDOM from 'react-dom';
    
    class App extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
        };
      }
      render() {
        return (
          <div>
            <h1>Hello, World!</h1>
          </div>
        );
      }
    }
    
    ReactDOM.render(<App />, document.getElementById('app'));
    <!-- filename: ./index.html -->
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Hello React!</title>
    </head>
    <body>
        <div id="app"></div>
        <!-- build:js -->
        <script src="./dist/src/bundle.js"></script>
        <!-- endbuild -->
    </body>
    </html>
  7. 設定 gulpfile.js

    // filename: gulpfile.js
    // 引入所有需要的檔案
    const gulp = require('gulp');
    const uglify = require('gulp-uglify');
    const htmlreplace = require('gulp-html-replace');
    const source = require('vinyl-source-stream');
    const browserify = require('browserify');
    const watchify = require('watchify');
    const babel = require('babelify');
    const streamify = require('gulp-streamify');
    // 檔案位置參數
    const path = {
      HTML: 'index.html',
      MINIFIED_OUT: 'bundle.min.js',
      OUT: 'bundle.js',
      DEST: 'dist',
      DEST_BUILD: 'dist/build',
      DEST_SRC: 'dist/src',
      ENTRY_POINT: './app/index.js'
    };
    // 複製 html 到 dist 資料夾中
    gulp.task('copy', function(){
      gulp.src(path.HTML)
        .pipe(gulp.dest(path.DEST));
    });
    // 監聽檔案是否有變化,若有變化則重新編譯一次
    gulp.task('watch', function() {
      gulp.watch(path.HTML, ['copy']);
    var watcher  = watchify(browserify({
        entries: [path.ENTRY_POINT],
        transform: [babel],
        debug: true,
      }));
    return watcher.on('update', function () {
        watcher.bundle()
          .pipe(source(path.OUT))
          .pipe(gulp.dest(path.DEST_SRC))
          console.log('Updated');
      })
        .bundle()
        .pipe(source(path.OUT))
        .pipe(gulp.dest(path.DEST_SRC));
    });
    // 執行 build production 的流程(包括 uglify、轉譯等)
    gulp.task('copy', function(){
      browserify({
        entries: [path.ENTRY_POINT],
        transform: [babel],
      })
        .bundle()
        .pipe(source(path.MINIFIED_OUT))
        .pipe(streamify(uglify(path.MINIFIED_OUT)))
        .pipe(gulp.dest(path.DEST_BUILD));
    });
    // 將 script 引用換成 production 的檔案
    gulp.task('replaceHTML', function(){
      gulp.src(path.HTML)
        .pipe(htmlreplace({
          'js': 'build/' + path.MINIFIED_OUT
        }))
        .pipe(gulp.dest(path.DEST));
    });
    // 設定 NODE_ENV 為 production
    gulp.task('apply-prod-environment', function() {
        process.env.NODE_ENV = 'production';
    });
    
    // 若直接執行 gulp 會執行 gulp default 的任務:watch、copy。若跑 gulp production,則會執行 build、replaceHTML、apply-prod-environment
    gulp.task('production', ['build', 'replaceHTML', 'apply-prod-environment']);
    gulp.task('default', ['watch', 'copy']);
  8. 成果展示
    到目前為止我們的資料夾的結構應該會是這樣:

    一看就懂的 React 開發環境建置與 Webpack 入門教學

    接下來我們透過在終端機(terminal)下 gulp 指令來處理我們設定好的任務:

    // 當只有輸入 gulp 沒有輸入任務名稱時,gulp 會自動執行 default 的任務,我們這邊會執行 `watch` 和 `copy` 的任務,前者會監聽 `./app/index.js` 是否有改變,有的話則更新。後者則是會把 `index.html` 複製到 `./dist/index.html`
    $ gulp

    當執行完 gulp 後,我們可以發現多了一個 dist 資料夾

    一看就懂的 React 開發環境建置與 Webpack 入門教學

    如果我們是要進行 production 的應用程式開發的話,我們可以執行:

    // 當輸入 gulp production 時,gulp 會執行 production 的任務,我們這邊會執行 `replaceHTML`、`build` 和 `apply-prod-environment` 的任務,`build` 任務會進行轉譯和 `uglify`。`replaceHTML` 會取代 `index.html` 註解中的 `<script>` 引入檔案,變成引入壓縮和 `uglify` 後的 `./dist/build/bundle.min.js`。`apply-prod-environment` 則是會更改 `NODE_ENV` 變數,讓環境設定改為 `production`,有興趣的讀者可以參考[React 官網說明](https://facebook.github.io/react/downloads.html)
    $ gulp production

    此時我們可以在瀏覽器上打開我們的 ./dist/hello.html,就可以看到 Hello, world! 了!

(image via srinisoundarsitepointkeyholesoftwaresurvivejs)

9.1 🚪 任意門

回首頁 |

勘誤、提問或許願 |

10 React 開發環境設置與 Webpack 入門教學

React 開發環境設置與 Webpack 入門教學

10.1 前言

俗話說工欲善其事,必先利其器。寫程式也是一樣,搭建好開發環境後可以讓自己在後續開發上更加順利。因此本章接下來將討論 React 開發環境的兩種主要方式:CDN-based、 webpack(這邊我們就先不討論 TypeScript 的開發方式)。至於 browserify 搭配 Gulp 的方法則會放在補充資料中,讓讀者閱讀完本章後可以開始 React 開發之旅!

10.2 JavaScript 模組化

隨著網站開發的複雜度提昇,許多現代化的網站已不是單純的網站而已,更像是個富有互動性的網頁應用程式(Web App)。為了應付現代化網頁應用程式開發的需求,解決一些像是全域變數污染、低維護性等問題,JavaScript 在模組化上也有長足的發展。過去一段時間讀者們或許聽過像是 WebpackBrowserifymodule bundlersAMDCommonJSUMDES6 Module 等有關 JavaScript 模組化開發的專有名詞或工具,在前面一個章節我們也簡單介紹了關於 JavaScript 模組化的簡單觀念和規範介紹。若是讀者對於 JavaScript 模組化開發尚不熟悉的話推薦可以參考 這篇文章這篇文章 當作入門。

總的來說,使用模組化開發 JavaScript 應用程式主要有以下三種好處:

  1. 提昇維護性(Maintainability)
  2. 命名空間(Namespacing)
  3. 提供可重用性(Reusability)

而在 React 應用程式開發上更推薦使用像是 Webpack 這樣的 module bundlers 來組織我們的應用程式,但對於一般讀者來說 Webpack 強大而完整的功能相對複雜。為了讓讀者先熟悉 React 核心觀念(我們假設讀者已經有使用 JavaScriptjQuery 的基本經驗),我們將從使用 CDN 引入 <script> 的方式開始介紹:

React 開發環境設置與 Webpack 入門教學
使用 CDN-based 的開發方式缺點是較難維護我們的程式碼(當引入函式庫一多就會有很多 <script/>)且會容易遇到版本相容性問題,不太適合開發大型應用程式,但因為簡單易懂,適合教學上使用。

以下是 React 官方首頁的範例,以下使用 React v15.2.1

  1. 理解 ReactComponent 導向的應用程式設計
  2. 引入 react.jsreact-dom.js(react 0.14 後將 react-dom 從 react 核心分離,更符合 react 跨平台抽象化的定位)以及 babel-standalone 版 script(可以想成 babel 是翻譯機,翻譯瀏覽器看不懂的 JSXES6+ 語法成為瀏覽器看的懂得的 JavaScript。為了提昇效率,通常我們都會在伺服器端做轉譯,這點在 production 環境尤為重要)
  3. <body> 撰寫 React Component 要插入(mount)指定節點的地方:<div id="example"></div>
  4. 透過 babel 進行語言翻譯 React JSX 語法,babel 會將其轉為瀏覽器看的懂得 JavaScript。其代表意義是:ReactDOM.render(欲 render 的 Component 或 HTML 元素, 欲插入的位置)。所以我們可以在瀏覽器上打開我們的 hello.html,就可以看到 Hello, world! 。That’s it,我們第一個 React 應用程式就算完成了!
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello React!</title>
    <!-- 以下引入 react.js, react-dom.js(react 0.14 後將 react-dom 從 react 核心分離,更符合 react 跨平台抽象化的定位)以及 babel-core browser 版 -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.2.1/react.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.2.1/react-dom.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.18.1/babel.min.js"></script>
  </head>
  <body>
    <!-- 這邊的 id="example" 的 <div> 為 React Component 要插入的地方 -->
    <div id="example"></div>
    <!-- 以下就是包在 babel(透過進行語言翻譯)中的 React JSX 語法,babel 會將其轉為瀏覽器看的懂得 JavaScript -->
    <script type="text/babel">
      ReactDOM.render(
        <h1>Hello, world!</h1>,
        document.getElementById('example')
      );
    </script>
  </body>
</html>

在瀏覽器瀏覽最後成果:

React 開發環境設置與 Webpack 入門教學

10.3 Webpack

React 開發環境設置與 Webpack 入門教學

上面我們先簡單介紹了 CDN-based 的開發方式讓大家先對於 React 有個基本印象,但由於 CDN-based 的開發方式有不少缺點。所以接下來的 Webpack 將會是我們接下來範例的主要使用的開發工具。

Webpack 是一個模組打包工具(module bundler),以下列出 Webpack 的幾項主要功能:

  • 將 CSS、圖片與其他資源打包
  • 打包之前預處理(Less、CoffeeScript、JSX、ES6 等)檔案
  • 依 entry 文件不同,把 .js 分拆為多個 .js 檔案
  • 整合豐富的 Loader 可以使用(Webpack 本身僅能處理 JavaScript 模組,其餘檔案如:CSS、Image 需要載入不同 Loader 進行處理)

接下來我們一樣透過 Hello World 實例來介紹如何用 Webpack 設置 React 開發環境:

  1. 依據你的作業系統安裝 NodeNPM(目前版本的 Node 都會內建 NPM)

  2. 透過 NPM 安裝 Webpack(可以 global 或 local project 安裝,這邊我們使用 local)、webpack loader、webpack-dev-server

    Webpack 中的 loader 類似於 browserify 內的 transforms,但 Webpack 在使用上比較多元,除了 JavaScript loader 外也有 CSS Style 和圖片的 loader。此外,webpack-dev-server 則可以啟動開發用 server,方便我們測試

    // 按指示初始化 NPM 設定檔 package.json
    $ npm init 
    // --save-dev 是可以讓你將安裝套件的名稱和版本資訊存放到 package.json,方便日後使用
    $ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react html-webpack-plugin webpack webpack-dev-server
  3. 在根目錄設定 webpack.config.js

    事實上,webpack.config.js 有點類似於 gulp 中的 gulpfile.js 功用,主要是設定 webpack 的相關設定

    // 這邊使用 HtmlWebpackPlugin,將 bundle 好的 <script> 插入到 body。${__dirname} 為 ES6 語法對應到 __dirname  
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    
    const HTMLWebpackPluginConfig = new HtmlWebpackPlugin({
      template: `${__dirname}/app/index.html`,
      filename: 'index.html',
      inject: 'body',
    });
    
    module.exports = {
      // 檔案起始點從 entry 進入,因為是陣列所以也可以是多個檔案
      entry: [
        './app/index.js',
      ],
      // output 是放入產生出來的結果的相關參數
      output: {
        path: `${__dirname}/dist`,
        filename: 'index_bundle.js',
      },
      module: {
        // loaders 則是放欲使用的 loaders,在這邊是使用 babel-loader 將所有 .js(這邊用到正則式)相關檔案(排除了 npm 安裝的套件位置 node_modules)轉譯成瀏覽器可以閱讀的 JavaScript。preset 則是使用的 babel 轉譯規則,這邊使用 react、es2015。若是已經單獨使用 .babelrc 作為 presets 設定的話,則可以省略 query
        loaders: [
          {
            test: /\.js$/,
            exclude: /node_modules/,
            loader: 'babel-loader',
            query: {
              presets: ['es2015', 'react'],
            },
          },
        ],
      },
      // devServer 則是 webpack-dev-server 設定
      devServer: {
        inline: true,
        port: 8008,
      },
      // plugins 放置所使用的外掛
      plugins: [HTMLWebpackPluginConfig],
    };
  4. 在根目錄設定 .babelrc

    {
      "presets": [
        "es2015",
        "react",
      ],
      "plugins": []
    }
  5. 安裝 react 和 react-dom

    $ npm install --save react react-dom
  6. 撰寫 Component(記得把 index.html 以及 index.js 放到 app 資料夾底下喔!)
    index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>React Setup</title>
        <link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
    </head>
    <body>
        <!-- 欲插入 React Component 的位置 -->
        <div id="app"></div>
    </body>
    </html>

    index.js

    import React from 'react';
    import ReactDOM from 'react-dom';
    
    class App extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
        };
      }
      render() {
        return (
          <div>
            <h1>Hello, World!</h1>
          </div>
        );
      }
    }
    
    ReactDOM.render(<App />, document.getElementById('app'));
  7. 在終端機使用 webpack 進行成果展示,webpack 相關指令:

    • webpack:會在開發模式下開始一次性的建置
    • webpack -p:會建置 production 的程式碼
    • webpack –watch:會監聽程式碼的修改,當儲存時有異動時會更新檔案
    • webpack -d:加入 source maps 檔案
    • webpack –progress –colors:加上處理進度與顏色

    如果不想每次都打一長串的指令碼的話可以使用 package.json 中的 scripts 設定

    "scripts": {
      "dev": "webpack-dev-server --devtool eval --progress --colors --content-base build"
    }

    然後在終端機執行:

    $ npm run dev

當我們此時我們可以打開瀏覽器輸入 http://localhost:8008 ,就可以看到 Hello, world! 了!

10.4 總結

以上就是 React 開發環境設置與 Webpack 入門教學,看到這邊的讀者不妨自己動手設定開發環境,體驗一下 React 開發環境的感覺,畢竟若是只有閱讀文字的話很容易就會忘記喔!若你不想在環境設定上花太多時間的話,不妨參考 Facebook 開發社群推出的 create-react-app,可以快速上手,使用 Webpack、BabelESLint 開發 React 應用程式。接下來的章節我們將持續延伸 React/JSX/Component 的介紹。

10.5 延伸閱讀

  1. JavaScript 模块化七日谈
  2. 前端模块化开发那点历史
  3. Webpack 中文指南
  4. WEBPACK DEV SERVER

(image via srinisoundarsitepointkeyholesoftwaresurvivejs)

10.6 🚪 任意門

回首頁 | 上一章:React 生態系(Ecosystem)入門簡介 | 下一章:ReactJS 與 Component 設計入門介紹 |

勘誤、提問或許願 |

11 Ch03 React/JSX/Component 簡介

  1. ReactJS 與 Component 入門介紹
  2. JSX 簡明入門教學指南

11.1 🚪 任意門

| 回首頁 |

12 JSX 簡明入門教學指南

JSX 簡明入門教學指南

12.1 前言

根據 React 官方定義,React 是一個構建使用者介面的 JavaScritp Library。以 MVC 模式來說,ReactJS 主要是負責 View 的部份。過去一段時間,我們被灌輸了許多前端分離的觀念,在前端三兄弟中(或三姊妹、三劍客):HTML 掌管內容結構、CSS 負責外觀樣式,JavaScript 主管邏輯互動,千萬不要混在一塊。然而,在 React 世界裡,所有事物都是 以 Component 為基礎,將同一個 Component 相關的程式和資源都放在一起,而在撰寫 React Component 時我們通常會使用 JSX 的方式來提升程式撰寫效率。事實上,JSX 並非一種全新的語言,而是一種語法糖(Syntatic Sugar),一種語法類似 XML 的 ECMAScript 語法擴充。在 JSX 中 HTML 和組建這些元素標籤的程式碼有緊密的關係。因此你可能要熟悉一下以 Component 為單位的思考方式(本文主要使用 ES6 語法)。

此外,React 和 JSX 的思維在於善用 JavaScript 的強大能力,放棄蹩腳的模版語言,這和 Angular 強化 HTML 的理念也有所不同。當然 JSX 並非強制使用,你也可以選擇不用,因為最終 JSX 的內容會轉化成 JavaScript(瀏覽器只看的懂 JavaScript)。不過等你閱讀完接下來的內容,你或許會開始發現 JSX 的好,認真考慮使用 JSX 的語法。

12.2 一、使用 JSX 的好處

12.2.1 1. 提供更加語意化且易懂的標籤

由於 JSX 類似 XML 的語法,讓一些非開發人員也更容易看懂,且能精確定義包含屬性的樹狀結構。一般來說我們想做一個回饋表單,使用 HTML 寫法通常會長這樣:

<form class="messageBox">
  <textarea></textarea>
  <button type="submit"></button>
</form>

使用 JSX,就像 XML 語法結構一樣可以自行定義標籤且有開始和關閉,容易理解:

<MessageBox />

React 思路認為使用 Component 比起模版(Template)和顯示邏輯(Display Logic)更能實現關注點分離的概念,而搭配 JSX 可以實現聲明式 Declarative(注重 what to),而非命令式 Imperative(注重 how to)的程式撰寫方式:

Facebook 上面按讚功能

以 Facebook 上面按讚功能來說,若是命令式 Imperative 寫法大約會是長這樣:


if(userLikes()) {
  if(!hasBlueLike()) {
    removeGrayLike();
    addBlueLike();
  }
} else {
  if(hasBlueLike()) {
    removeBlueLike();
    addGrayLike();
  }
}

若是聲明式 Declarative 則是會長這樣:

if(this.state.liked) {
  return (<BlueLike />);
} else {
  return (<GrayLike />);
}

看完上述說明是不是感覺 React 結合 JSX 的寫法更易讀易懂?事實上,當 Component 組成越來越複雜時,若使用 JSX 將可以讓整個結構更加直觀,可讀性較高。

12.2.2 2. 更加簡潔

雖然最終 JSX 會轉換成 JavaScript,但使用 JSX 可以讓程式看起來更加簡潔,以下為使用 JSX 和不使用 JSX 的範例:

<a href="https://facebook.github.io/react/">Hello!</a>

不使用 JSX(記得我們說過 JSX 是選用的):

// React.createElement(元件/HTML標籤, 元件屬性,以物件表示, 子元件)
React.createElement('a', {href: 'https://facebook.github.io/react/'}, 'Hello!')

12.2.3 3. 結合原生 JavaScript 語法

JSX 並非一種全新的語言,而是一種語法糖(Syntatic Sugar),一種語法類似 XML 的 ECMAScript 語法擴充,所以並沒有改變 JavaScript 語意。透過結合 JavaScript ,可以釋放 JavaScript 語言本身能力。下面例子就是運用 map 方法,輕易把 result 值迭代出來,產生無序清單(ul)的內容,不用再使用蹩腳的模版語言(這邊有個小地方要留意的是每個 <li> 元素記得加上獨特的 key 這邊用 map function 迭代出的 index,不然會出現問題):

// const 為常數
const lists = ['JavaScript', 'Java', 'Node', 'Python'];

class HelloMessage extends React.Component {
  render() {
    return (
    <ul>
      {lists.map((result, index) => {
        return (<li key={index}>{result}</li>);
      })}
    </ul>);
  }
}

12.3 二、JSX 用法摘要

12.3.1 1. 環境設定與使用方式

初步了解為何要使用 JSX 後,我們來聊聊 JSX 的用法。一般而言 JSX 通常有兩種使用方式:

  1. 使用 browserifywebpackCommonJS bundler 並整合 babel 預處理
  2. 於瀏覽器端做解析

在這邊簡單起見,我們先使用第二種方式,先讓大家專注熟悉 JSX 語法使用,等到後面章節再教大家使用 bundler 的方式去做解析(可以試著把下面的原始碼貼到 JSbin 的 HTML 看結果):

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello React!</title>
    <!-- 請先於 index.html 中引入 react.js, react-dom.js 和 babel-core 的 browser.min.js -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.1/react.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.1/react-dom.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.min.js"></script>
  </head>
  <body>
    <div id="example"></div>
    <script type="text/babel">
      // 程式碼寫在這邊!
      ReactDOM.render(
        <h1>Hello, world!</h1>,
        document.getElementById('example')
      );
    </script>
  </body>
</html>

一般載入 JSX 方式有:

  • 內嵌
<script type="text/babel">
  ReactDOM.render(
    <h1>Hello, world!</h1>,
    document.getElementById('example')
  );
</script>
  • 從外部引入

<script type="text/jsx" src="main.jsx"></script>

12.3.2 2. 標籤用法

JSX 標籤非常類似 XML ,可以直接書寫。一般 Component 命名首字大寫,HTML Tags 小寫。以下是一個建立 Component 的 class:

class HelloMessage extends React.Component {
  render() {
    return (
      <div>
        <p>Hello React!</p>
        <MessageList />
      </div>
    );
  }
}

12.3.3 3. 轉換成 JavaScript

JSX 最終會轉換成瀏覽器可以讀取的 JavaScript,以下為其規則:

React.createElement(
  string/ReactClass, // 表示 HTML 元素或是 React Component
  [object props], // 屬性值,用物件表示
  [children] // 接下來參數皆為元素子元素
)

解析前(特別注意在 JSX 中使用 JavaScript 表達式時使用 {} 括起,如下方範例的 text,裡面對應的是變數。若需放置一般文字,請加上 ''):

var text = 'Hello React';
<h1>{text}</h1>
<h1>{'text'}</h1>

解析完後:

var text = 'Hello React';
React.createElement("h1", null, "Hello React!");

另外要特別要注意的是由於 JSX 最終會轉成 JavaScript 且每一個 JSX 節點都對應到一個 JavaScript 函數,所以在 Component 的 render 方法中只能回傳一個根節點(Root Nodes)。例如:若有多個 <div>render 請在外面包一個 Component 或 <div><span> 元素。

12.3.4 4. 註解

由於 JSX 最終會編譯成 JavaScript,註解也一樣使用 ///**/ 當做註解方式:

// 單行註解

/*
  多行註解
*/

var content = (
  <List>
      {/* 若是在子元件註解要加 {}  */}
      <Item
        /* 多行
           註解
           喔 */
        name={window.isLoggedIn ? window.name : ''} // 單行註解
      />
  </List>
);

12.3.5 5. 屬性

在 HTML 中,我們可以透過標籤上的屬性來改變標籤外觀樣式,在 JSX 中也可以,但要注意 classfor 由於為 JavaScript 保留關鍵字用法,因此在 JSX 中使用 classNamehtmlFor 替代。

class HelloMessage extends React.Component {
  render() {
    return (
      <div className="message">
        <p>Hello React!</p>
      </div>
    );
  }
}

12.3.5.1 Boolean 屬性

在 JSX 中預設只有屬性名稱但沒設值為 true,例如以下第一個 input 標籤 disabled 雖然沒設值,但結果和下面的 input 為相同:

<input type="button" disabled />;
<input type="button" disabled={true} />;

反之,若是沒有屬性,則預設預設為 false

<input type="button" />;
<input type="button" disabled={false} />;

12.3.6 6. 擴展屬性

在 ES6 中使用 ... 是迭代物件的意思,可以把所有物件對應的值迭代出來設定屬性,但要注意後面設定的屬性會蓋掉前面相同屬性:

var props = {
  style: "width:20px",
  className: "main",
  value: "yo",  
}

<HelloMessage  {...props} value="yo" />

// 等於以下
React.createElement("h1", React._spread({}, props, {value: "yo"}), "Hello React!");

12.3.7 7. 自定義屬性

若是希望使用自定義屬性,可以使用 data-

<HelloMessage data-attr="xd" />

12.3.8 8. 顯示 HTML

通常為了避免資訊安全問題,我們會過濾掉 HTML,若需要顯示的話可以使用:

<div>{{_html: '<h1>Hello World!!</h1>'}}</div>

12.3.9 9. 樣式使用

在 JSX 中使用外觀樣式方法如下,第一個 {} 是 JSX 語法,第二個為 JavaScript 物件。與一般屬性值用 - 分隔不同,為駝峰式命名寫法:

<HelloMessage style={{ color: '#FFFFFF', fontSize: '30px'}} />

12.3.10 10. 事件處理

事件處理為前端開發的重頭戲,在 JSX 中透過 inline 事件的綁定來監聽並處理事件(注意也是駝峰式寫法),更多事件處理方法請參考官網

<HelloMessage onClick={this.onBtn} />

12.4 總結

以上就是 JSX 簡明入門教學,希望透過以上介紹,讓讀者了解在 React 中為何要使用 JSX,以及 JSX 基本概念和用法。最後為大家複習一下:在 React 世界裡,所有事物都是以 Component 為基礎,通常會將同一個 Component 相關的程式和資源都放在一起,而在撰寫 React Component 時我們常會使用 JSX 的方式來提升程式撰寫效率。JSX 是一種語法類似 XML 的 ECMAScript 語法擴充,可以善用 JavaScript 的強大能力,放棄蹩腳的模版語言。當然 JSX 並非強制使用,你也可以選擇不用,因為最終 JSX 的內容會轉化成 JavaScript。當相信閱讀完上述的內容後,你會開始認真考慮使用 JSX 的語法。

12.5 延伸閱讀

  1. Imperative programming or declarative programming
  2. JSX in Depth
  3. 從零開始學 React(ReactJS 101)

(image via adweek, codecondo

12.6 🚪 任意門

回首頁 | 上一章:ReactJS 與 Component 設計入門介紹 | 下一章:Props、State、Refs 與表單處理 |

勘誤、提問或許願 |

13 ReactJS 與 Component 設計入門介紹

13.1 前言

在上一個章節中我們快速學習了 React 開發環境建置和 Webpack 入門。接下來我們將更進一步了解 React 和 Component 設計時需注意的幾個重要特性。

13.2 ReactJS 特性簡介

React 原本是 Facebook 自己內部使用的開發工具,但卻是一個目標遠大的一個專案:Learn once, write anywhere。自從 2013 年開源後周邊的生態系更是蓬勃發展。ReactJS 的出現讓前端開發有許多革新性的思維出現,其中有幾個重要特性值得我們去探討:

  1. 基於元件(Component)化思考
  2. 用 JSX 進行宣告式(Declarative)UI 設計
  3. 使用 Virtual DOM
  4. Component PropType 防呆機制
  5. Component 就像個狀態機(State Machine),而且也有生命週期(Life Cycle)
  6. 一律重繪(Always Redraw)和單向資料流(Unidirectional Data Flow)
  7. 在 JavaScript 裡寫 CSS:Inline Style

13.3 基於元件(Component)化思考

ReactJS 與 Component 設計入門介紹

在 React 的世界中最基本的單元為元件(Component),每個元件也可以包含一個以上的子元件,並依照需求組裝成一個組合式的(Composable)元件,因此具有封裝(encapsulation)、關注點分離 (Separation of Concerns)、複用 (Reuse) 、組合 (Compose) 等特性。

<TodoApp> 元件可以包含 <TodoHeader /><TodoList /> 子元件

    <div>
        <TodoHeader />
        <TodoList />
    </div>

<TodoList /> 元件內部長相:

    <div>
        <ul>
            <li>寫程式碼</li>
            <li>哄妹子</li>
            <li>買書</li>
        </ul>
    </div>

元件化一直是網頁前端開發的聖杯,許多開發者最希望的就是可以最大化重複使用(reuse)過去所寫的程式碼,不要重複造輪子(DRY)。在 React 中元件是一切的基礎,讓開發應用程式就好像在堆積木一樣。然而對於過去習慣模版式(template)開發的前端工程師來說,短時間要轉換成元件化思考模式並不容易,尤其過去我們往往習慣於將 HTML、CSS 和 JavaScript 分離,現在卻要把它們都封裝在一起。

一個比較好的方式就是訓練自己看到不同的網頁或應用程式時,強迫自己將看到的頁面切成一個個元件。相信過了一段時間後,天眼開了,就比較容易習慣元件化的思考方式。

以下是一般 React Component 撰寫的主要兩種方式:

  1. 使用 ES6 的 Class(可以進行比較複雜的操作和元件生命週期的控制,相對於 stateless components 耗費資源)

    //  注意元件開頭第一個字母都要大寫
    class MyComponent extends React.Component {
        // render 是 Class based 元件唯一必須的方法(method)
        render() {
            return (
                <div>Hello, World!</div>
            );
        }
    }
    
    // 將 <MyComponent /> 元件插入 id 為 app 的 DOM 元素中
    ReactDOM.render(<MyComponent/>, document.getElementById('app'));
  2. 使用 Functional Component 寫法(單純地 render UI 的 stateless components,沒有內部狀態、沒有實作物件和 ref,沒有生命週期函數。若非需要控制生命週期的話建議多使用 stateless components 獲得比較好的效能)

    // 使用 arrow function 來設計 Functional Component 讓 UI 設計更單純(f(D) => UI),減少副作用(side effect)
    const MyComponent = () => (
        <div>Hello, World!</div>
    );
    
    // 將 <MyComponent /> 元件插入 id 為 app 的 DOM 元素中
    ReactDOM.render(<MyComponent/>, document.getElementById('app'));

13.4 用 JSX 進行宣告式(Declarative)UI 設計

React 在設計上的思路認為使用 Component 比起模版(Template)和顯示邏輯(Display Logic)更能實現關注點分離的概念,而搭配 JSX 可以實現聲明式 Declarative(注重 what to),而非命令式 Imperative(注重 how to)的程式撰寫方式。

像下述的宣告式(Declarative)UI 設計就比單純用(Template)式的方式更易懂:

// 使用宣告式(Declarative)UI 設計很容易可以看出這個元件的功能
<MailForm />
// <MailForm /> 內部長相
<form>
    <input type="text" name="email" />
    <button type="submit"></button>
</form>

由於 JSX 在 React 元件撰寫上扮演很重要的角色,因此在下一個章節我們也將更深入講解 JSX 使用細節。

13.5 使用 Virtual DOM

在傳統 Web 中一般是使用 jQuery 進行 DOM 的直接操作。然而更改 DOM 往往是 Web 效能的瓶頸,因此在 React 世界設計有 Virtual DOM 的機制,讓 App 和 DOM 之間用 Virtual DOM 進行溝通。當更改 DOM 時,會透過 React 自身的 diff 演算法去計算出最小更新,進而去最小化更新真實的 DOM。

13.6 Component PropType 防呆機制

在 React 設計時除了提供 props 預設值設定(Default Prop Values)外,也提供了 Prop 的驗證(Validation)機制,讓整個 Component 設計更加穩健:

//  注意元件開頭第一個字母都要大寫
class MyComponent extends React.Component {
    // render 是 Class based 元件唯一必須的方法(method)
    render() {
        return (
            <div>Hello, World!</div>
        );
    }
}

// PropTypes 驗證,若傳入的 props type 不符合將會顯示錯誤
MyComponent.propTypes = {
  todo: React.PropTypes.object,
  name: React.PropTypes.string,
}

// Prop 預設值,若對應 props 沒傳入值將會使用 default 值
MyComponent.defaultProps = {
 todo: {}, 
 name: '', 
}

關於更多的 Validation 用法可以參考官方網站 的說明。

13.7 Component 就像個狀態機(State Machine),而且也有生命週期(Life Cycle)

Component 就像個狀態機(State Machine),根據不同的 state(透過 setState() 修改)和 props(由父元素傳入),Component 會出現對應的顯示結果。而人有生老病死,元件也有生命週期。透過操作生命週期處理函數,可以在對應的時間點進行 Component 需要的處理,關於更詳細的元件生命週期介紹我們會再下一個章節進行更一步說明。

13.8 一律重繪(Always Redraw)和單向資料流(Unidirectional Data Flow)

在 React 世界中,props 和 state 是影響 React Component 長相的重要要素。其中 props 都是由父元素所傳進來,不能更改,若要更改 props 則必須由父元素進行更改。而 state 則是根據使用者互動而產生的不同狀態,主要是透過 setState() 方法進行修改。當 React 發現 props 或是 state 更新時,就會重繪整個 UI。當然你也可以使用 forceUpdate() 去強迫重繪 Component。而 React 透過整合 Flux 或 Flux-like(例如:Redux)可以更具體實現單向資料流(Unidirectional Data Flow),讓資料流的管理更為清晰。

13.9 在 JavaScript 裡寫 CSS:Inline Style

在 React Component 中 CSS 使用 Inline Style 寫法,全都封裝在 JavaScript 當中:

const divStyle = {
  color: 'red',
  backgroundImage: 'url(' + imgUrl + ')',
};

ReactDOM.render(<div style={divStyle}>Hello World!</div>, document.getElementById('app'));

13.10 總結

以上介紹了 ReactJS 的幾個重要特性:

  1. 基於元件(Component)化思考
  2. 用 JSX 進行宣告式(Declarative)UI 設計
  3. 使用 Virtual DOM
  4. Component PropType 防呆機制
  5. Component 就像個狀態機(State Machine),而且也有生命週期(Life Cycle)
  6. 一律重繪(Always Redraw)和單向資料流(Unidirectional Data Flow)
  7. 在 JavaScript 裡寫 CSS:Inline Style

接下來我們將進一步探討 React 裡 JSX 的使用方式。

13.11 延伸閱讀

  1. React 入门实例教程
  2. React Demystified
  3. Top-Level API
  4. ES6 Classes Component

(image via maketea

13.12 🚪 任意門

回首頁 | 上一章:React 開發環境設置與 Webpack 入門教學 | 下一章:JSX 簡明入門教學指南 |

勘誤、提問或許願 |

14 Ch04 Props/State 基礎與 Component 生命週期

  1. Props、State、Refs 與表單處理
  2. React Component 規格與生命週期(Life Cycle)

14.1 🚪 任意門

| 回首頁 |

15 Props、State、Refs 與表單處理

15.1 前言

在前面的章節中我們已經對於 React 和 JSX 有初步的認識,我們也了解到 React Component 事實上可以視為顯示 UI 的一個狀態機(state machine),而這個狀態機根據不同的 state(透過 setState() 修改)和 props(由父元素傳入),Component 會出現對應的顯示結果。本章將使用 React 官網首頁上的範例(使用 ES6+)來更進一步說明 Props 和 State 特性及在 React 如何進行事件和表單處理。

15.2 Props

首先我們使用 React 官網上的 A Simple Component 來說明 props 的使用方式。由於傳入元件的 name 屬性為 Mark,故以下程式碼將會在瀏覽器顯示 Hello, Mark。針對傳入的 props 我們也有驗證和預設值的設計,讓我們撰寫的元件可以更加穩定健壯(robust)。

HTML Markup:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>A Component Using External Plugins</title>
</head>
<body>
<!-- 這邊方便使用 CDN 方式引入 react 、 react-dom 進行講解,實務上和實戰教學部分我們會使用 webpack -->
<script src="https://fb.me/react-15.1.0.js"></script>
<script src="https://fb.me/react-dom-15.1.0.js"></script>
  <div id="app"></div>
    <script src="./app.js"></script>
</body>
</html>

app.js,使用 ES6 Class Component 寫法:

class HelloMessage extends React.Component {
    // 若是需要綁定 this.方法或是需要在 constructor 使用 props,定義 state,就需要 constructor。若是在其他方法(如 render)使用 this.props 則不用一定要定義 constructor
    constructor(props) {
        // 對於 OOP 物件導向程式設計熟悉的讀者應該對於 constructor 建構子的使用不陌生,事實上它是 ES6 的語法糖,骨子裡還是 prototype based 物件導向程式語言。透過 extends 可以繼承 React.Component 父類別。super 方法可以呼叫繼承父類別的建構子
        super(props);
        this.state = {}
    }
    // render 是唯一必須的方法,但如果是單純 render UI 建議使用 Functional Component 寫法,效能較佳且較簡潔
    render() {
        return (
            <div>Hello {this.props.name}</div>
        )
    }
}

// PropTypes 驗證,若傳入的 props type 不是 string 將會顯示錯誤
HelloMessage.propTypes = {
  name: React.PropTypes.string,
}

// Prop 預設值,若對應 props 沒傳入值將會使用 default 值 Zuck
HelloMessage.defaultProps = {
 name: 'Zuck',
}

ReactDOM.render(<HelloMessage name="Mark" />, document.getElementById('app'));

關於 React ES6 class constructor super() 解釋可以參考 React ES6 class constructor super()

使用 Functional Component 寫法:

// Functional Component 可以視為 f(d) => UI,根據傳進去的 props 繪出對應的 UI。注意這邊 props 是傳入函式的參數,因此取用 props 不用加 this
const HelloMessage = (props) => (
    <div>Hello {props.name}</div>
);

// PropTypes 驗證,若傳入的 props type 不是 string 將會顯示錯誤
HelloMessage.propTypes = {
  name: React.PropTypes.string,
}

// Prop 預設值,若對應 props 沒傳入值將會使用 default 值 Zuck。用法等於 ES5 的 getDefaultProps
HelloMessage.defaultProps = {
 name: 'Zuck',
}

ReactDOM.render(<HelloMessage name="Mark" />, document.getElementById('app'));

在 jsbin 上的範例:

<a class=“jsbin-embed” href=“http://jsbin.com/wadice/embed?html,js,console,output”>A Component Using External Plugins on jsbin.com</a><script src=“http://static.jsbin.com/js/embed.min.js?3.39.12”></script>

15.3 State

接下來我們將使用 A Stateful Component 這個範例來講解 State 的用法。在 React Component 可以自己管理自己的內部 state,並用 this.state 來存取 state。當 setState() 方法更新了 state 後將重新呼叫 render() 方法,重新繪製 component 內容。以下範例是一個每 1000 毫秒(等於1秒)就會加一的累加器。由於這個範例是 Stateful Component 因此僅使用 ES6 Class Component,而不使用 Functional Component。

HTML Markup:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>A Component Using External Plugins</title>
</head>
<body>
<script src="https://fb.me/react-15.1.0.js"></script>
<script src="https://fb.me/react-dom-15.1.0.js"></script>
  <div id="app"></div>
    <script src="./app.js"></script>
</body>
</html>

app.js:

class Timer extends React.Component {
    constructor(props) {
        super(props);
        // 與 ES5 React.createClass({}) 不同的是 component 內自定義的方法需要自行綁定 this context,或是使用 arrow function
        this.tick = this.tick.bind(this);
        // 初始 state,等於 ES5 中的 getInitialState
        this.state = {
            secondsElapsed: 0,
        }
    }
    // 累加器方法,每一秒被呼叫後就會使用 setState() 更新內部 state,讓 Component 重新 render
    tick() {
        this.setState({secondsElapsed: this.state.secondsElapsed + 1});
    }
    // componentDidMount 為 component 生命週期中階段 component 已插入節點的階段,通常一些非同步操作都會放置在這個階段。這便是每1秒鐘會去呼叫 tick 方法
    componentDidMount() {
        this.interval = setInterval(this.tick, 1000);
    }
    // componentWillUnmount 為 component 生命週期中 component 即將移出插入的節點的階段。這邊移除了 setInterval 效力
    componentWillUnmount() {
        clearInterval(this.interval);
    }
    // render 為 class Component 中唯一需要定義的方法,其回傳 component 欲顯示的內容
    render() {
        return (
          <div>Seconds Elapsed: {this.state.secondsElapsed}</div>
        );
    }
}

ReactDOM.render(<Timer />, document.getElementById('app'));

關於 Javascript this 用法可以參考 Javascript:this用法整理

15.4 事件處理(Event Handle)

在前面的內容我們已經學會如何使用 props 和 state,接下來我們要更進一步學習在 React 內如何進行事件處理。下列將使用 React 官網的 An Application 當做例子,實作出一個簡單的 TodoApp。

HTML Markup:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>A Component Using External Plugins</title>
</head>
<body>
<script src="https://fb.me/react-15.1.0.js"></script>
<script src="https://fb.me/react-dom-15.1.0.js"></script>
  <div id="app"></div>
    <script src="./app.js"></script>
</body>
</html>

app.js:

// TodoApp 元件中包含了顯示 Todo 的 TodoList 元件,Todo 的內容透過 props 傳入 TodoList 中。由於 TodoList 僅單純 Render UI 不涉及內部 state 操作是 stateless component,所以使用 Functional Component 寫法。需要特別注意的是這邊我們用 map function 來迭代 Todos,需要留意的是每個迭代的元素必須要有 unique key 不然會發生錯誤(可以用自定義 id,或是使用 map function 的第二個參數 index)
const TodoList = (props) => (
    <ul>
        {
            props.items.map((item) => (
                <li key={item.id}>{item.text}</li>
            ))
        }
    </ul>
)

// 整個 App 的主要元件,這邊比較重要的是事件處理的部份,內部有
class TodoApp extends React.Component {
    constructor(props) {
        super(props);
        this.onChange = this.onChange.bind(this);
        this.handleSubmit = this.handleSubmit.bind(this);
        this.state = {
            items: [],
            text: '',
        }
    }
    onChange(e) {
        this.setState({text: e.target.value});
    }
    handleSubmit(e) {
        e.preventDefault();
        const nextItems = this.state.items.concat([{text: this.state.text, id: Date.now()}]);
        const nextText = '';
        this.setState({items: nextItems, text: nextText});
    }
    render() {
        return (
          <div>
            <h3>TODO</h3>
            <TodoList items={this.state.items} />
            <form onSubmit={this.handleSubmit}>
              <input onChange={this.onChange} value={this.state.text} />
              <button>{'Add #' + (this.state.items.length + 1)}</button>
            </form>
          </div>
        );
    }
}

ReactDOM.render(<TodoApp />, document.getElementById('app'));

以上介紹了 React 事件處理的部份,除了 onChangeonSubmit 外,React 也封裝了常用的事件處理,如 onClick 等。若想更進一步了解有哪些可以使用的事件處理方法可以參考 官網的 Event System

15.5 Refs 與表單處理

上面介紹了 props(傳入後就不能修改)、state(隨著使用者互動而改變)和事件處理機制後,我們將接續介紹如何在 React 中進行表單處理。同樣我們使用 React 官網範例 A Component Using External Plugins 進行介紹。由於 React 可以容易整合外部的 libraries(例如:jQuery),本範例將使用 remarkable 結合 ref 屬性取出 DOM Value 值(另外比較常用的作法是使用 onChange 事件處理方式處理表單內容),讓使用者可以使用 Markdown 語法的所見即所得編輯器(editor)。

HTML Markup(除了引入 reactreact-dom 還要用 CDN 方式引入 remarkable 這個 Markdown 語法 parser 套件,記得如果沒有使用 Webpack 或是 browserify + babelify 等工具需要引入 babel-standalone 瀏覽器解析 ES6 語法並於引入 script 加上 type=“text/babel”):

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>A Component Using External Plugins</title>
</head>
<body>
<script src="https://fb.me/react-15.1.0.js"></script>
<script src="https://fb.me/react-dom-15.1.0.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.18.1/babel.min.js"></script>
<script src="https://cdn.jsdelivr.net/remarkable/1.6.2/remarkable.min.js"></script>
  <div id="app"></div>
    <script type="text/babel" src="./app.js"></script>
</body>
</html>

app.js:

class MarkdownEditor extends React.Component {
    constructor(props) {
        super(props);
        this.handleChange = this.handleChange.bind(this);
        this.rawMarkup = this.rawMarkup.bind(this);
        this.state = {
            value: 'Type some *markdown* here!',
        }
    }
    handleChange() {
        this.setState({value: this.refs.textarea.value});
    }
    // 將使用者輸入的 Markdown 語法 parse 成 HTML 放入 DOM 中,React 通常使用 virtual DOM 作為和 DOM 溝通的中介,不建議直接由操作 DOM。故使用時的屬性為 dangerouslySetInnerHTML
    rawMarkup() {
        const md = new Remarkable();
        return { __html: md.render(this.state.value) };
    }
    render() {
        return (
          <div className="MarkdownEditor">
            <h3>Input</h3>
            <textarea
              onChange={this.handleChange}
              ref="textarea"
              defaultValue={this.state.value} />
            <h3>Output</h3>
            <div
              className="content"
              dangerouslySetInnerHTML={this.rawMarkup()}
            />
          </div>
        );
    }
}

ReactDOM.render(<MarkdownEditor />, document.getElementById('app'));

15.6 總結

以上透過幾個 React 官網首頁上的範例介紹了 Props 和 State 特性及在 React 如何進行事件和表單處理這些 React 中核心的問題,若還不熟悉的讀者建議重新親自動手照著範例中的程式碼敲過一遍,也可以使用像 jsbin 這樣所見即所得的工具來練習,更能熟悉相關語法和 API 喔!接下來我們將探討 Component 的生命週期。

15.7 延伸閱讀

  1. React 官方網站
  2. Top-Level API
  3. Javascript:this用法整理

15.8 🚪 任意門

回首頁 | 上一章:JSX 簡明入門教學指南 | 下一章:React Component 規格與生命週期(Life Cycle) |

勘誤、提問或許願 |

16 React Component 規格與生命週期(Life Cycle)

16.1 前言

經過前面的努力相信目前讀者對於用 React 開發一些簡單的元件(Component)已經有一定程度的掌握了,現在我們將更細部探討 React Component 的規格和其生命週期。

16.2 React Component 規格

若讀者還有印象的話,我們前面介紹 React 特性時有描述 React 的主要撰寫方式有兩種:一種是使用 ES6 Class,另外一種是 Stateless Components,使用 Functional Component 的寫法,單純渲染 UI。這邊再幫大家複習一下上一個章節的簡單範例:

  1. 使用 ES6 的 Class(可以進行比較複雜的操作和元件生命週期的控制,相對於 stateless components 耗費資源)

    //  注意元件開頭第一個字母都要大寫
    class MyComponent extends React.Component {
        // render 是 Class based 元件唯一必須的方法(method)
        render() {
            return (
                <div>Hello, {this.props.name}</div>
            );
        }
    }
    
    // PropTypes 驗證,若傳入的 props type 不符合將會顯示錯誤
    MyComponent.propTypes = {
        name: React.PropTypes.string,
    }
    
    // Prop 預設值,若對應 props 沒傳入值將會使用 default 值,為每個實例化 Component 共用的值
    MyComponent.defaultProps = {
        name: '',
    }
    
    // 將 <MyComponent /> 元件插入 id 為 app 的 DOM 元素中
    ReactDOM.render(<MyComponent name="Mark"/>, document.getElmentById('app'));
  2. 使用 Functional Component 寫法(單純地 render UI 的 stateless components,沒有內部狀態、沒有實作物件和 ref,沒有生命週期函數。若非需要控制生命週期的話建議多使用 stateless components 獲得比較好的效能)

    // 使用 arrow function 來設計 Functional Component 讓 UI 設計更單純(f(D) => UI),減少副作用(side effect)
    const MyComponent = (props) => (
        <div>Hello, {props.name}</div>
    );
    
    // PropTypes 驗證,若傳入的 props type 不符合將會顯示錯誤
    MyComponent.propTypes = {
        name: React.PropTypes.string,
    }
    
    // Prop 預設值,若對應 props 沒傳入值將會使用 default 值
    MyComponent.defaultProps = {
        name: '',
    }
    
    // 將 <MyComponent /> 元件插入 id 為 app 的 DOM 元素中
    ReactDOM.render(<MyComponent name="Mark"/>, document.getElmentById('app'));

值得留意的是在 ES6 Class 中 render() 是唯一必要的方法(但要注意的是請保持 render() 的純粹,不要在裡面進行 state 修改或是使用非同步方法和瀏覽器互動,若需非同步互動請於 componentDidMount() 操作),而 Functional Component 目前允許 return null 值。 喔對了,在 ES6 中也不支援 mixins 複用其他元件的方法了。

16.3 React Component 生命週期

React Component,就像人會有生老病死一樣有生命週期。一般而言 Component 有以下三種生命週期的狀態:

  1. Mounting:已插入真實的 DOM
  2. Updating:正在被重新渲染
  3. Unmounting:已移出真實的 DOM

針對 Component 的生命週期狀態 React 也有提供對應的處理方法:

  1. Mounting
    • componentWillMount()
    • componentDidMount()
  2. Updating
    • componentWillReceiveProps(object nextProps):已載入元件收到新的參數時呼叫
    • shouldComponentUpdate(object nextProps, object nextState):元件判斷是否重新渲染時呼叫,起始不會呼叫除非呼叫 forceUpdate()
    • componentWillUpdate(object nextProps, object nextState)
    • componentDidUpdate(object prevProps, object prevState)
  3. Unmounting
    • componentWillUnmount()

很多讀者一開始學習 Component 生命週期時會覺得很抽象,所以接下來用一個簡單範例讓大家感受一下 Component 的生命週期。讀者可以發現當一開始載入元件時第一個會觸發 console.log('constructor');,依序執行 componentWillMountcomponentDidMount ,而當點擊文字觸發 handleClick() 更新 state 時則會依序執行 componentWillUpdatecomponentDidUpdate

HTML Markup:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <script src="https://fb.me/react-15.1.0.js"></script>
  <script src="https://fb.me/react-dom-15.1.0.js"></script>
  <title>Component LifeCycle</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>

Component 生命週期展示:

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    console.log('constructor');
    this.handleClick = this.handleClick.bind(this);
    this.state = {
      name: 'Mark',
    }
  }
  handleClick() {
    this.setState({'name': 'Zuck'});
  }
  componentWillMount() {
    console.log('componentWillMount');
  }
  componentDidMount() {
    console.log('componentDidMount');
  }
  componentWillReceiveProps() {
    console.log('componentWillReceiveProps');
  }
  componentWillUpdate() {
    console.log('componentWillUpdate');
  }
  componentDidUpdate() {
    console.log('componentDidUpdate');
  }
  componentWillUnmount() {
    console.log('componentWillUnmount');
  }
  render() {
    return (
      <div onClick={this.handleClick}>Hi, {this.state.name}</div>
    );
  }
}

ReactDOM.render(<MyComponent />, document.getElementById('app'));

<a class=“jsbin-embed” href=“http://jsbin.com/yokebo/embed?html,js,console,output”>點擊看詳細範例</a><script src=“http://static.jsbin.com/js/embed.min.js?3.39.12”></script>

React Component 規格與生命週期

其中特殊處理的函數 shouldComponentUpdate,目前預設 return true。若你想要優化效能可以自己編寫判斷方式,若採用 immutable 可以使用 nextProps === this.props 比對是否有變動:

shouldComponentUpdate(nextProps, nextState) {
  return nextProps.id !== this.props.id;
}

16.4 Ajax 非同步處理

若有需要進行 Ajax 非同步處理,請在 componentDidMount 進行處理。以下透過 jQuery 執行 Ajax 取得 Github API 資料當做範例:

HTML Markup:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <script src="https://fb.me/react-15.1.0.js"></script>
  <script src="https://fb.me/react-dom-15.1.0.js"></script>
  <script src="https://code.jquery.com/jquery-3.1.0.js"></script>
  <title>GitHub User</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>

app.js

class UserGithub extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
          username: '',
          githubtUrl: '',
          avatarUrl: '',
        }
    }
    componentDidMount() {
        $.get(this.props.source, (result) => {
            console.log(result);
            const data = result;
            if (data) {
              this.setState({
                    username: data.name,
                    githubtUrl: data.html_url,
                    avatarUrl: data.avatar_url
              });
            }
        });
    }
    render() {
        return (
          <div>
            <h3>{this.state.username}</h3>
            <img src={this.state.avatarUrl} />
            <a href={this.state.githubtUrl}>Github Link</a>.
          </div>
        );
    }
}

ReactDOM.render(
  <UserGithub source="https://api.github.com/users/torvalds" />,
  document.getElementById('app')
);

<a class=“jsbin-embed” href=“http://jsbin.com/kupusa/embed?html,js,output”>點擊看詳細範例</a><script src=“http://static.jsbin.com/js/embed.min.js?3.39.12”></script>

16.5 總結

以上介紹了 React Component 規格與生命週期(Life Cycle)的概念,其中生命週期的概念對於初學者來說可能會比較抽象,建議讀者跟著範例動手實作。接下來我們將更進一步介紹 React Router 讓讀者感受一下單頁式應用程式(single page application)的設計方式。

16.6 延伸閱讀

  1. Component Specs and Lifecycle

(image via react-lifecycle

16.7 🚪 任意門

回首頁 | 上一章:Props、State、Refs 與表單處理 | 下一章:React Router 入門實戰教學 |

勘誤、提問或許願 |

17 Ch05 React Router

  1. React Router 入門實戰教學

17.1 🚪 任意門

| 回首頁 |

18 React Router 入門實戰教學

React Router 資料夾結構

18.1 前言

若你是從一開始一路走到這裡讀者請先給自己一個愛的鼓勵吧!在經歷了 React 基礎的訓練後,相信各位讀者應該都等不及想大展拳腳了!接下來我們將進行比較複雜的應用程式開發並和讀者介紹目前市場上常見的不刷頁單頁式應用程式(single page application)的設計方式。

18.2 單頁式應用程式(single page application)

傳統的 Web 開發主要是由伺服器管理 URL Routing 和渲染 HTML 頁面,過往每次 URL 一換或使用者連結一點,就需要重新從伺服器端重新載入頁面。但隨著使用者對於使用者體驗的要求提昇,許多的網頁應用程式紛紛設計成不刷頁的單頁式應用程式(single page application),由前端負責 URL 的 routing 管理,若需要和後端進行 API 資料溝通的話,通常也會使用 Ajax 的技術。在 React 開發世界中主流是使用 react-router 這個 routing 管理用的 library。

18.3 React Router 環境設置

先透過以下指令在根目錄產生 npm 設定檔 package.json

$ npm init

安裝相關套件(包含開發環境使用的套件):

$ npm install --save react react-dom react-router
$ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react webpack webpack-dev-server html-webpack-plugin

安裝好後我們可以設計一下我們的資料夾結構,首先我們在根目錄建立 srcres 資料夾,分別放置 scriptsource 和靜態資源(如:全域使用的 .css 和圖檔)。在 components 資料夾中我們會放置所有 components(個別元件資料夾中會用 index.js 輸出元件,讓引入元件更簡潔),其餘設定檔則放置於根目錄下。

React Router 資料夾結構

接下來我們先設定一下開發文檔。

  1. 設定 Babel 的設定檔: .babelrc

    {
        "presets": [
        "es2015",
        "react",
        ],
        "plugins": []
    }
  2. 設定 ESLint 的設定檔和規則: .eslintrc

    {
      "extends": "airbnb",
      "rules": {
        "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
      },
      "env" :{
        "browser": true,
      }
    }
  3. 設定 Webpack 設定檔: webpack.config.js

    // 讓你可以動態插入 bundle 好的 .js 檔到 .index.html
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    
    const HTMLWebpackPluginConfig = new HtmlWebpackPlugin({
      template: `${__dirname}/src/index.html`,
      filename: 'index.html',
      inject: 'body',
    });
    
    // entry 為進入點,output 為進行完 eslint、babel loader 轉譯後的檔案位置
    module.exports = {
      entry: [
        './src/index.js',
      ],
      output: {
        path: `${__dirname}/dist`,
        filename: 'index_bundle.js',
      },
      module: {
        preLoaders: [
          {
            test: /\.jsx$|\.js$/,
            loader: 'eslint-loader',
            include: `${__dirname}/src`,
            exclude: /bundle\.js$/
          }
        ],
        loaders: [{
          test: /\.js$/,
          exclude: /node_modules/,
          loader: 'babel-loader',
          query: {
            presets: ['es2015', 'react'],
          },
        }],
      },
      // 啟動開發測試用 server 設定(不能用在 production)
      devServer: {
        inline: true,
        port: 8008,
      },
      plugins: [HTMLWebpackPluginConfig],
    };

太好了!這樣我們就完成了開發環境的設定可以開始動手實作 React Router 應用程式了!

18.4 開始 React Routing 之旅

HTML Markup:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>ReactRouter</title>
  <link rel="stylesheet" type="text/css" href="../res/styles/main.css">
</head>
<body>
    <div id="app"></div>
</body>
</html>

以下是 webpack.config.js 的進入點 src/index.js,負責管理 Routerrender 元件。這邊我們要先詳細討論的是,為了使用 React Router 功能引入了許多 react-router 內部的元件。

  1. Router
    Router 是放置 Route 的容器,其本身不定義 routing ,真正 routing 規則由 Route 定義。

  2. Route
    Route 負責 URL 和對應的元件關係,可以有多個 Route 規則也可以有嵌套(nested)Routing。像下面的例子就是每個頁面都會先載入 App 元件再載入對應 URL 的元件。

  3. history
    Router 中有一個屬性 history 的規則,這邊使用我們使用 hashHistory,使用 routing 將由 hash(#)變化決定。例如:當使用者拜訪 http://www.github.com/,實際看到的會是 http://www.github.com/#/。下列範例若是拜訪了 /about 則會看到 http://localhost:8008/#/about 並載入 App 元件再載入 About 元件。

    • hashHistory
      教學範例使用的,會通過 hash 進行對應。好處是簡單易用,不用多餘設定。

    • browserHistory
      適用於伺服器端渲染,但需要設定伺服器端避免處理錯誤,這部份我們會在後面的章節詳細說明。注意的是若是使用 Webpack 開發用伺服器需加上 --history-api-fallback

    $ webpack-dev-server --inline --content-base . --history-api-fallback
    • createMemoryHistory
      主要用於伺服器渲染,使用上會建立一個存在記憶體的 history 物件,不會修改瀏覽器的網址位置。
    const history = createMemoryHistory(location)
  4. path
    path 是對應 URL 的規則。例如:/repos/torvalds 會對應到 /repos/:name 的位置,並將參數傳入 Repos 元件中。由 this.props.params.name 取得參數。順帶一提,若為查詢參數 /user?q=torvalds 則由 this.props.location.query.q 取得參數。

  5. IndexRoute
    由於 / 情況下 App 元件對應的 this.props.children 會是 undefinded,所以使用 IndexRoute 來解決對應問題。這樣當 URL 為 / 時將會對應到 Home 元件。不過要注意的是 IndexRoute 沒有 path 屬性。

import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Route, hashHistory, IndexRoute } from 'react-router';
import App from './components/App';
import Home from './components/Home';
import Repos from './components/Repos';
import About from './components/About';
import User from './components/User';
import Contacts from './components/Contacts';

ReactDOM.render(
  <Router history={hashHistory}>
    <Route path="/" component={App}>
      <IndexRoute component={Home} />
      <Route path="/repos/:name" component={Repos} />
      <Route path="/about" component={About} />
      <Route path="/user" component={User} />
      <Route path="/contacts" component={Contacts} />
    </Route>
  </Router>,
  document.getElementById('app'));

  /* 另外一種寫法:
    const routes = (
        <Route path="/" component={App}>
          <IndexRoute component={Home} />
          <Route path="/repos/:name" component={Repos} />
          <Route path="/about" component={About} />
          <Route path="/user" component={User} />
          <Route path="/contacts" component={Contacts} />
        </Route>
    );

    ReactDOM.render(
      <Router routes={routes} history={hashHistory} />,
      document.getElementById('app'));
  */

由於我們在 index.js 使用嵌套 routing,把 App 元件當做每個元件都會載入的母模版,亦即進入每個對應頁面載入對應元件前都會先載入 App 元件。這樣就可以讓每個頁面都有導覽列連結可以點選,同時可以透過 props.children 載入對應 URL 的子元件。

  1. Link
    Link 元件主要用於點擊後連結轉換,可以想成是 <a> 超連結的 React 版本。若是希望當點擊時候有對應的 css style,可以使用 activeStyleactiveClassName 去做設定。範例分別使用於 index.html使用傳統 CSS 載入、Inline Style、外部引入 Inline Style 寫法。

  2. IndexLink
    IndexLink 主要是了處理 index 用途,特別注意當 child route actived 時,parent route 也會 actived。所以我們回首頁的連結使用 <IndexLink /> 內部的 onlyActiveOnIndex 屬性來解決這個問題。

  3. Redirect、IndexRedirect
    這邊雖然沒有用到,但若讀者有需要使用到連結跳轉的話可以參考這兩個元件,用法類似於 RouteIndexRedirect

以下是 src/components/App/App.js 完整程式碼:

import React from 'react';
import { Link, IndexLink } from 'react-router';
import styles from './appStyles';
import NavLink from '../NavLink';

const App = (props) => (
  <div>
    <h1>React Router Tutorial</h1>
    <ul>
      <li><IndexLink to="/" activeClassName="active">Home</IndexLink></li>
      <li><Link to="/about" activeStyle={{ color: 'green' }}>About</Link></li>
      <li><Link to="/repos/react-router" activeStyle={styles.active}>Repos</Link></li>
      <li><Link to="/user" activeClassName="active">User</Link></li>
      <li><NavLink to="/contacts">Contacts</NavLink></li>
    </ul>
    <!-- 我們將 App 元件當做每個元件都會載入的母模版,因此可以透過 children 載入對應 URL 的子元件 -->
    {props.children}
  </div>
);

App.propTypes = {
  children: React.PropTypes.object,
};

export default App;

對應的元件內部使用 Functional Component 進行 UI 渲染:

以下是 src/components/Repos/Repos.js 完整程式碼:

import React from 'react';

const Repos = (props) => (
  <div>
    <h3>Repos</h3>
    <h5>{props.params.name}</h5>
  </div>
);

Repos.propTypes = {
  params: React.PropTypes.object,
};

export default Repos;

詳細的程式碼讀者可以參考範例資料夾,若讀者跟著範例完成的話,可以在終端機上執行 npm start,並於瀏覽器 http://localhost:8008看到以下成果,當你點選連結時會切換對應元件並改變 actived 狀態!

範例成果

18.5 總結

到這邊我們又一起完成了一個重要的一關,學習 routing 對於使用 React 開發複雜應用程式是非常重要的一步,接下來我們將一起學習一個相對獨立的單元 ImmutableJS,但學習 ImmutableJS 可以讓我們在使用 ReactFlux/Redux 可以有更好的效能和避免一些副作用。

18.6 延伸閱讀

  1. Leveling Up With React: React Router
  2. Programmatically navigate using react router
  3. React Router 使用教程
  4. React Router 中文文档
  5. React Router Tutorial

(iamge via seanamarasinghe

18.7 任意門

回首頁 | 上一章:React Component 規格與生命週期(Life Cycle) | 下一章:ImmutableJS 入門教學 |

勘誤、提問或許願 |

19 Ch06 ImmutableJS

  1. ImmutableJS 入門教學

19.1 🚪 任意門

| 回首頁 |

20 ImmutableJS 入門教學

ImmutableJS

20.1 前言

一般來說在 JavaScript 中有兩種資料類型:Primitive(String、Number、Boolean、null、undefinded)和 Object(Reference)。在 JavaScript 中物件的操作比起 Java 容易很多,但也因為相對彈性不嚴謹,所以產生了一些問題。在 JavaScript 中的 Object(物件)資料是 Mutable(可以變的),由於是使用 Reference 的方式,所以當修改到複製的值也會修改到原始值。例如下面的 map2 值是指到 map1,所以當 map1 值一改,map2 的值也會受影響。

var map1 = { a: 1 }; 
var map2 = map1; 
map2.a = 2

通常一般作法是使用 deepCopy 來避免修改,但這樣作法會產生較多的資源浪費。為了很好的解決這個問題,我們可以使用 Immutable Data,所謂的 Immutable Data 就是一旦建立,就不能再被修改的數據資料。

為了解決這個問題,在 2013 年時 Facebook 工程師 Lee Byron 打造了 ImmutableJS,但並沒有被預設放到 React 工具包中(雖然有提供簡化的 Helper),但 ImmutableJS 的出現確實解決了 React 甚至 Redux 所遇到的一些問題。

以下範例即是引入了 ImmutableJS 的效果,讀者可以發現,雖然我們操作了 map1 的值,但會發現原本的 map1 並未受到影響(因為任何修改都不會影響到原始資料),雖然使用 deepCopy 也可以模擬類似的效果但會浪費過多的計算資源和記憶體,ImmutableJS 則可以容易地共享沒有被修該到的資料(例如下面的資料 b 即為 map1map2 共享),因而有更好的效能表現。

import Immutable from 'immutable';

var map1 = Immutable.Map({ a: 1, b: 3 });
var map2 = map1.set('a', 2);

map1.get('a'); // 1
map2.get('a'); // 2

20.2 ImmutableJS 特性介紹

ImmutableJS 提供了 7 種不可修改的資料類型:ListMapStackOrderedMapSetOrderedSetRecord。若是對 Immutable 物件操作都會回傳一個新值。其中比較常用的有 ListMapSet

  1. Map:類似於 key/value 的 object,在 ES6 也有原生 Map 對應

```javascript
const Map= Immutable.Map;

// 1. Map 大小
const map1 = Map({ a: 1 });
map1.size
// => 1

// 2. 新增或取代 Map 元素
// set(key: K, value: V)
const map2 = map1.set(‘a’, 7);
// => Map { “a”: 7 }

// 3. 刪除元素
// delete(key: K)
const map3 = map1.delete(‘a’);
// => Map {}

// 4. 清除 Map 內容
const map4 = map1.clear();
// => Map {}

// 5. 更新 Map 元素
// update(updater: (value: Map<K, V>) => Map<K, V>)
// update(key: K, updater: (value: V) => V)
// update(key: K, notSetValue: V, updater: (value: V) => V)
const map5 = map1.update(‘a’, () => (7))
// => Map { “a”: 7 }

// 6. 合併 Map
const map6 = Map({ b: 3 });
map1.merge(map6);
// => Map { “a”: 1, “b”: 3 }
```

  1. List:有序且可以重複值,對應於一般的 Array

```javascript
const List= Immutable.List;

// 1. 取得 List 長度
const arr1 = List([1, 2, 3]);
arr1.size
// => 3

// 2. 新增或取代 List 元素內容
// set(index: number, value: T)
// 將 index 位置的元素替換
const arr2 = arr1.set(-1, 7);
// => [1, 2, 7]
const arr3 = arr1.set(4, 0);
// => [1, 2, 3, undefined, 0]

// 3. 刪除 List 元素
// delete(index: number)
// 刪除 index 位置的元素
const arr4 = arr1.delete(1);
// => [1, 3]

// 4. 插入元素到 List
// insert(index: number, value: T)
// 在 index 位置插入 value
const arr5 = arr1.insert(1, 2);
// => [1, 2, 2, 3]

// 5. 清空 List
// clear()
const arr6 = arr1.clear();
// => []
```

  1. Set:沒有順序且不能重複的列表

```javascript
const Set= Immutable.Set;

// 1. 建立 Set
const set1 = Set([1, 2, 3]);
// => Set { 1, 2, 3 }

// 2. 新增元素
const set2 = set1.add(1).add(5);
// => Set { 1, 2, 3, 5 }
// 由於 Set 為不能重複集合,故 1 只能出現一次

// 3. 刪除元素
const set3 = set1.delete(3);
// => Set { 1, 2 }

// 4. 取聯集
const set4 = Set([2, 3, 4, 5, 6]);
set1.union(set4);
// => Set { 1, 2, 3, 4, 5, 6 }

// 5. 取交集
set1.intersect(set4);
// => Set { 2, 3 }

// 6. 取差集
set1.subtract(set4);
// => Set { 1 }
```

20.3 ImmutableJS 的特性整理

  1. Persistent Data Structure
    ImmutableJS 的世界裡,只要資料一被創建,就不能修改,維持 Immutable。就不會發生下列的狀況:

```javascript
var obj = {
a: 1
};

funcationA(obj);
console.log(obj.a) // 不確定结果為多少?
```

使用 ImmutableJS 就沒有這個問題:

`javascript // 有些開發者在使用時會在Immutable變數前加$` 以示區隔。

const $obj = fromJS({
a: 1
});

funcationA($obj);
console.log($obj.get(‘a’)) // 1
```

  1. Structural Sharing
    為了維持資料的不可變,又要避免像 deepCopy 一樣複製所有的節點資料而造成的資源損耗,在 ImmutableJS 使用的是 Structural Sharing 特性,亦即如果物件樹中一個節點發生變化的話,只會修改這個節點和和受它影響的父節點,其他節點則共享。

```javascript
const obj = {
count: 1,
list: [1, 2, 3, 4, 5]
}
var map1 = Immutable.fromJS(obj);
var map2 = map1.set(‘count’, 4);

console.log(map1.list === map2.list); // true
```

  1. Support Lazy Operation

```javascript
Immutable.Range(1, Infinity)
.map(n => -n)
// Error: Cannot perform this action with an infinite size.

Immutable.Range(1, Infinity)
.map(n => -n)
.take(2)
.reduce((r, n) => r + n, 0);
// -3
```

  1. 豐富的 API 並提供快速轉換原生 JavaScript 的方式
    在 ImmutableJS 中可以使用 fromJS()toJS() 進行 JavaScript 和 ImmutableJS 之間的轉換。但由於在轉換之間會非常耗費資源,所以若是你決定引入 ImmutableJS 的話請盡量維持資料處在 Immutable 的狀態。

  2. 支持 Functional Programming
    Immutable 本身就是 Functional Programming(函數式程式設計)的概念,所以在 ImmutableJS 中可以使用許多 Functional Programming 的方法,例如:mapfiltergroupByreducefindfindIndex 等。

  3. 容易實現 Redo/Undo 歷史回顧

20.4 React 效能優化

ImmutableJS 除了可以和 Flux/Redux 整合外,也可以用於基本 react 效能優化。以下是一般使用效能優化的簡單方式:

傳統 JavaScript 比較方式,若資料型態為 Primitive 就不會有問題:

// 在 shouldComponentUpdate 比較接下來的 props 是否一致,若相同則不重新渲染,提昇效能
shouldComponentUpdate (nextProps) {
    return this.props.value !== nextProps.value;
}

但當比較的是物件的話就會出現問題:

// 假設 this.props.value 為 { foo: 'app' }
// 假設 nextProps.value 為 { foo: 'app' },
// 雖然兩者值是一樣,但由於 reference 位置不同,所以視為不同。但由於值一樣應該要避免重複渲染
this.props.value !== nextProps.value; // true

使用 ImmutableJS

var SomeRecord = Immutable.Record({ foo: null });
var x = new SomeRecord({ foo: 'app'  });
var y = x.set('foo', 'azz');
x === y; // false

在 ES6 中可以使用官方文件上的 PureRenderMixin 進行比較,可以讓程式碼更簡潔:

import PureRenderMixin from 'react-addons-pure-render-mixin';
class FooComponent extends React.Component {
  constructor(props) {
    super(props);
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
  }
  render() {
    return <div className={this.props.className}>foo</div>;
  }
}

20.5 總結

雖然 ImmutableJS 的引入可以帶來許多好處和效能的提升但由於引入整體檔案較大且較具侵入性,在引入之前可以自行評估看看是否合適於目前的專案。接下來我們將在後面的章節講解如何將 ImmutableJSRedux 整合應用到實務上的範例。

20.6 延伸閱讀

  1. 官方網站
  2. Immutable.js初识
  3. Immutable 详解及 React 中实践
  4. 为什么需要Immutable.js
  5. facebook immutable.js 意义何在,使用场景?
  6. React 巢狀 Component 效能優化
  7. PureRenderMixin
  8. seamless-immutable
  9. Immutable Data Structures and JavaScript

(image via risingstack

20.7 🚪 任意門

回首頁 | 上一章:React Router 入門實戰教學 | 下一章:Flux 基礎概念與實戰入門 |

勘誤、提問或許願 |

21 Ch07 Flux/Redux

  1. Flux 基礎概念與實戰入門
  2. Redux 基礎概念
  3. Redux 實戰入門

21.1 🚪 任意門

| 回首頁 |

22 Flux 基礎概念與實戰入門

React Flux

22.1 前言

隨著 React App 複雜度提昇,我們會發現常常需要從 Parent Component 透過 props 傳遞方法到 Child Component 去改變 state tree,不但不方便也難以管理,因此我們需要更好的資料架構來建置更複雜的應用程式。Flux 是 Facebook 推出的 client-side 應用程式架構(Architecture),主要想解決 MVC 架構的一些問題。事實上,Flux 並非一個完整的前端 Framework,其特色在於實現了 Unidirectional Data Flow(單向流)的資料流設計模式,在開發複雜的大型應用程式時可以更容易地管理 state(狀態)。由於 React 主要是負責 View 的部份,所以透過搭配 Flux-like 的資料處理架構,可以更好的去管理我們的 state(狀態),處理複雜的使用者互動(例如:Facebook 同時要維護使用者是否按讚、點擊相片,是否有新訊息等狀態)。

由於原始的 Flux 架構在實現上有些部分可以精簡和改善,在實務上我們通常會使用開發者社群開發的 Flux-like 相關的架構實現(例如:ReduxAltReflux 等)。不過這邊我們主要會使用 Facebook 本身提供 Dispatcher API 函式庫(可以想成是一個 pub/sub 處理器,透過 broadcast 將 payloads 傳給註冊的 callback function)並搭配 NodeJSEventEmitter 模組去完成 Flux 架構的實現。

22.2 Flux 概念介紹

React Flux

在 Flux Unidirectional Data Flow(單項流)世界裡有四大主角,分別負責不同對應的工作:

  1. actions / Action Creator

    action 負責定義所有改變 state(狀態)的行為,可以讓開發者快速了解 App 的各種功能,若你想改變 state 你只能發 action。注意 action 可以是同步或是非同步。例如:新增代辦事項,呼叫非同步 API 獲取資料。

    實務上我們會分成 action 和 Action Creator。action 為描述行為的 object(物件),Action Creator 將 action 送給 dispatcher。一般來說符合 Flux Standard Action 的 action 會如以下範例程式碼,具備 type 來區別所觸發的行為。而 payload 則是所夾帶的資料:

    // action
    const addTodo = {
      type: 'ADD_TODO',
      payload: {
        text: 'Do something.'  
      }
    }
    
    AppDispatcher.dispatch(addTodo);

    當發生 rejected Promise 情況:

    {
      type: 'ADD_TODO',
      payload: new Error(),
      error: true
    }
  2. Dispatcher

    Dispatcher 是 Flux 架構的核心,每個 App 只有一個 Dispatcher,提供 API 讓 store 可以註冊 callback function,並負責向所有 store 發送 action 事件。在本範例中我們使用 Facebook 提供的 Dispatcher API,其內建有 dispatchsubscribe 方法。

  3. Stores

    一個 App 通常會有多個 store 負責存放業務邏輯,根據不同業務會有不同 store,例如:TodoStore、RecipeStore。 store 負責操作和儲存資料並提供 view 使用 listener(監聽器),若有資料更新即會觸發更新。值得注意的是 store 只提供 getter API 讀取資料,若想改變 state 一律發送 action。

  4. Views(Controller Views)

    這部份是 React 負責的範疇,負責提供監聽事件的 callback function,當事件發生時重新取得資料並重繪 View

22.3 Flux 流程回顧

React Flux

Flux 架構前置作業:

  1. Stores 向 Dispatcher 註冊 callback,當資料改變時告知 Stores
  2. Controller Views 向 Stores 取得初始資料
  3. Controller Views 將資料給 Views 去渲染 UI
  4. Controller Views 向 store 註冊 listener,當資料改變時告知 Controller Views

Flux 與使用者互動運作流程:

  1. 使用者和 App 互動,觸發事件,Action Creator 發送 actions 給 Dispatcher
  2. Dispatcher 依序將 action 傳給 store 並由 action type 判斷合適的處理方式
  3. 若有資料更新則會觸發 Controller Views 向 store 註冊的 listener 並向 store 取得更新資料
  4. View 根據 Controller Views 的新資料重新繪製 UI

22.4 Flux 實戰初體驗

介紹完了整個 Flux 基本架構後,接下來我們就來動手實作一個簡單 Flux 架構的 Todo,讓使用者可以在 input 輸入代辦事項並新增。

首先,我們先完成一些開發的前置作業,先透過以下指令在根目錄產生 npm 設定檔 package.json

$ npm init

安裝相關套件(包含開發環境使用的套件):

$ npm install --save react react-dom flux events
$ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react html-webpack-plugin webpack webpack-dev-server

安裝好後我們可以設計一下我們的資料夾結構,首先我們在根目錄建立 src,放置 scriptsource 。在 components 資料夾中我們會放置所有 components(個別元件資料夾中會用 index.js 輸出元件,讓引入元件更簡潔),另外還有 actionsconstantsdispatcherstores,其餘設定檔則放置於根目錄下。

React Flux 資料夾結構

接下來我們參考上一章設定一下開發文檔(.babelrc.eslintrcwebpack.config.js)。這樣我們就完成了開發環境的設定可以開始動手實作 React Flux 應用程式了!

HTML Markup:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>TodoFlux</title>
</head>
<body>
    <div id="app"></div>
</body>
</html>

以下為 src/index.js 完整程式碼,安排了父 component 和在 HTML Markup 插入位置:

import React from 'react';
import ReactDOM from 'react-dom';
import TodoHeader from './components/TodoHeader';
import TodoList from './components/TodoList';

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {};
  }
  render() {
    return (
      <div>
        <TodoHeader />
        <TodoList />
      </div>
    );
  }
}

ReactDOM.render(<App />, document.getElementById('app'));

通常實務上我們會開一個 constants 資料夾存放 config 或是 actionTypes 常數。以下是 src/constants/actionTypes.js

export const ADD_TODO = 'ADD_TODO';

在這個範例中我們繼承了 Facebook 提供的 Dispatcher API(主要是繼承了 dispatchregistersubscribe 的方法),打造自己的 DispatcherClass,當使用者觸發 handleAction()dispatch 出事件。以下是 src/dispatch/AppDispatcher.js

// Todo app dispatcher with actions responding to both
// view and server actions
import { Dispatcher } from 'flux';

class DispatcherClass extends Dispatcher {
  handleAction(action) {
    this.dispatch({
      type: action.type,
      payload: action.payload,
    });
  }
}

const AppDispatcher = new DispatcherClass();

export default AppDispatcher;

以下是我們利用 AppDispatcher 打造的 Action CreatorhandleAction 負責發出傳入的 action ,完整程式碼如 src/actions/todoActions.js

import AppDispatcher from '../dispatcher/AppDispatcher';
import { ADD_TODO } from '../constants/actionTypes';

export const TodoActions = {
  addTodo(text) {
    AppDispatcher.handleAction({
      type: ADD_TODO,
      payload: {
        text,
      },
    });
  },
};

Store 主要是負責資料以及業務邏輯處理,我們繼承了 events 模組的 EventEmitter,當 action 傳入 AppDispatcher.register 的處理範圍後,根據 action type 選擇適合處理的 store 進行處理,處理完後透過 emit 方法發出事件讓監聽的 Views Controller 知道。以下是 src/stores/TodoStore.js

import AppDispatcher from '../dispatcher/AppDispatcher';
import { ADD_TODO } from '../constants/actionTypes';
import { EventEmitter } from 'events';

const store = {
  todos: [],
  editing: false,
};

class TodoStoreClass extends EventEmitter {
  addChangeListener(callback) {
    this.on(ADD_TODO, callback);
  }
  removeChangeListener(callback) {
    this.removeListener(ADD_TODO, callback);
  }
  getTodos() {
    return store.todos;
  }
}

const TodoStore = new TodoStoreClass();

AppDispatcher.register((action) => {
  switch (action.type) {
    case ADD_TODO:
      store.todos.push(action.payload.text);
      TodoStore.emit(ADD_TODO);
      break;
    default:
      return true;
  }
  return true;
});

export default TodoStore;

在這個 React Flux 範例中我們把 ViewViews Controller 整合在一起。在 TodoHeader 中,我們主要任務是讓使用者可以透過 input 新增代辦事項。使用者輸入文字在 input 時會觸發 onChange 事件,進而更新內部的 state,當使用者按了送出鈕就會觸發 onAdd 事件,dispatchaddTodo event。以下是 src/components/TodoHeader.js 完整範例:

import React, { Component } from 'react';
import { TodoActions } from '../../actions/todoActions';

class TodoHeader extends Component {
  constructor(props) {
    super(props);
    this.onChange = this.onChange.bind(this);
    this.onAdd = this.onAdd.bind(this);
    this.state = {
      text: '',
      editing: false,
    };
  }
  onChange(event) {
    this.setState({
      text: event.target.value,
    });
  }
  onAdd() {
    TodoActions.addTodo(this.state.text);
    this.setState({
      text: '',
    });
  }
  render() {
    return (
      <div>
        <h1>TodoFlux</h1>
        <div>
          <input
            value={this.state.text}
            type="text"
            placeholder="請輸入代辦事項"
            onChange={this.onChange}
          />
          <button
            onClick={this.onAdd}
          >
            送出
          </button>
        </div>
      </div>
    );
  }
}

export default TodoHeader;

在上面的 Component 中我們讓使用者可以新增代辦事項,接下來我們要讓新增的代辦事項可以顯示。我們在 componentDidMount 設了一個監聽器 TodoStore 資料改變時會去把資料重新再更新,這樣當使用者新增代辦事項時 TodoList 就會保持同步。當以下是 src/components/TodoList.js 完整程式碼:

import React, { Component } from 'react';
import TodoStore from '../../stores/TodoStore';

function getAppState() {
  return {
    todos: TodoStore.getTodos(),
  };
}
class TodoList extends Component {
  constructor(props) {
    super(props);
    this.onChange = this.onChange.bind(this);
    this.state = {
      todos: [],
    };
  }
  componentDidMount() {
    TodoStore.addChangeListener(this.onChange);
  }
  onChange() {
    this.setState(getAppState());
  }
  render() {
    return (
      <div>
        <ul>
          {
            this.state.todos.map((todo, key) => (
              <li key={key}>{todo}</li>
            ))
          }
        </ul>
      </div>
    );
  }
}

export default TodoList;

若讀者都有跟著上面的步驟走完的話,最後我們在終端機的根目錄位置執行 npm start 就可以看到整個成果囉,YA!
React Flux

22.5 總結

Flux 優勢:

  1. 讓開發者可以快速了解整個 App 中的行為
  2. 資料和業務邏輯統一存放好管理
  3. 讓 View 單純化只負責 UI 的排版不需負責 state 管理
  4. 清楚的架構和分工對於複雜中大型應用程式易於維護和管理程式碼

Flux 劣勢:

  1. 程式碼上不夠簡潔
  2. 對於簡單小應用來說稍微複雜

以上就是 Flux 的實戰入門,我知道一開始接觸 Flux 的讀者一定會覺得很抽象,有些讀者甚至會覺得這個架構到底有什麼好處(明明感覺沒比 MVC 高明到哪去或是一點都不簡潔),但如同上述優點所說 Flux 設計模式的優勢在於清楚的架構和分工對於複雜中大型應用程式易於維護和管理程式碼。若還是不熟悉的讀者可以跟著範例多動手,相信慢慢就可以體會 Flux 的特色。事實上,在開發社群中為了讓 Flux 架構更加簡潔,產生了許多 Flux-like 的架構和函式庫,接下來將帶讀者們進入目前最熱門的架構:Redux

22.6 延伸閱讀

  1. Getting To Know Flux, the React.js Architecture
  2. Flux 官方網站
  3. 從 Flux 與 MVC 的差異來簡介 Flux
  4. Flux Stores and ES6
  5. React and Flux: Migrating to ES6 with Babel and ESLint
  6. Building an ES6/JSX/React Flux App – Part 2 – The Flux
  7. Question: How to choose between Redux’s store and React’s state? #1287
  8. acdlite/flux-standard-action

(image via devjournalfacebookscotch.io

22.7 🚪 任意門

回首頁 | 上一章:ImmutableJS 入門教學 | 下一章:Redux 基礎概念 |

勘誤、提問或許願 |

23 Redux 基礎概念

React Redux

23.1 前言

前面一個章節我們講解了 Flux 的功能和用法,但在實務上許多開發者較偏好的是同為 Flux-like 但較為簡潔且文件豐富清楚的 Redux 當作狀態資料管理的架構。Redux 是由 Dan Abramov 所發起的一個開源的 library,其主要功能如官方首頁寫著:Redux is a predictable state container for JavaScript apps.,亦即 Redux 希望能提供一個可以預測的 state 管理容器,讓開發者可以可以更容易開發複雜的 JavaScript 應用程式(注意 Redux 和 React 並無相依性,只是和 React 可以有很好的整合)。

23.2 Flux/Redux 超級比一比

從簡單 Flux/Redux 比較圖可以看出兩者之間有些差異:

React Redux

在開始實作 Redux App 之前我們先來了解一下 Redux 和 Flux 的一些差異:

  1. 只使用一個 store 將整個應用程式的狀態 (state) 用物件樹 (object tree) 的方式儲存起來:

    原生的 Flux 會有許多分散的 store 儲存各個不同的狀態,但在 redux 中,只會有唯一一個 store 將所有的資料用物件的方式包起來。

    //原生 Flux 的 store
    const userStore = {
        name: ''
    }
    const todoStore = {
        text: ''
    }
    
    // Redux 的單一 store
    const state = {
        userState: {
            name: ''
        },
        todoState: {
            text: ''
        }
    }
  2. 唯一可以改變 state 的方法就是發送 action,這部份和 Flux 類似,但 Redux 並沒有像 Flux 設計有 Dispatcher。Redux 的 action 和 Flux 的 action 都是一個包含 typepayload 的物件。

  3. Redux 擁有 Flux 所沒有的 Reducer。Reducer 根據 action 的 type 去執行對應的 state 做變化的函式叫做 Reducer。你可以使用 switch 或是使用函式 mapping 的方式去對應處理的方式。

  4. Redux 擁有許多方便好用的輔助測試工具(例如:redux-devtoolsreact-transform-boilerplate),方便測試和使用 Hot Module Reload

23.3 Redux 核心概念介紹

React Redux

從上述的圖中我們可以看到 Redux 資料流的模型大致上可以簡化成: View -> Action -> (Middleware) -> Reducer。當使用者和 View 互動時會觸發事件發出 Action,若有使用 Middleware 的話會在進入 Reducer 進行一些處理,當 Action 進到 Reducer 時,Reducer 會根據,action type 去 mapping 對應處理的動作,然後回傳回新的 state。View 則因為偵測到 state 更新而重繪頁面。在這個章節我們討論的是 synchronous(同步)的情形,asynchronous(非同步)的狀況會在接下來的章節進行討論。以下就用官方網站上的簡單範例來讓大家感受一下 Redux 的整個使用流程:

import { createStore } from 'redux';

/** 
  下面是一個簡單的 reducers ,主要功能是針對傳進來的 action type 判斷並回傳新的 state
  reducer 規格:(state, action) => newState 
  一般而言 state 可以是 primitive、array 或 object 甚至是 ImmutableJS Data。但要留意的是不能修改到原來的 state ,
  回傳的是新的 state。由於使用在 Redux 中使用 ImmutableJS 有許多好處,所以我們的範例 App 也會使用 ImmutableJS 
*/
function counter(state = 0, action) {
  switch (action.type) {
  case 'INCREMENT':
    return state + 1;
  case 'DECREMENT':
    return state - 1;
  default:
    return state;
  }
}

// 創建 Redux store 去存放 App 的所有 state
// store 的可用 API { subscribe, dispatch, getState } 
let store = createStore(counter);

// 可以使用 subscribe() 來訂閱 state 是否更新。但實務通常會使用 react-redux 來串連 React 和 Redux
store.subscribe(() =>
  console.log(store.getState());
);

// 若想改變 state ,一律發 action
store.dispatch({ type: 'INCREMENT' });
// 1
store.dispatch({ type: 'INCREMENT' });
// 2
store.dispatch({ type: 'DECREMENT' });
// 1

23.4 Redux API 入門

  1. createStore:createStore(reducer, [preloadedState], [enhancer])

    我們知道在 Redux 中只會有一個 store。在產生 store 時我們會使用 createStore 這個 API 來創建 store。第一個參數放入我們的 reducer 或是有多個 reducers combine(使用 combineReducers)在一起的 rootReducers。第二個參數我們會放入希望預先載入的 state 例如:user session 等。第三個參數通常會放入我們想要使用用來增強 Redux 功能的 middlewares,若有多個 middlewares 的話,通常會使用 applyMiddleware 來整合。

  2. Store

    屬於 Store 的四個方法:

    • getState()
    • dispatch(action)
    • subscribe(listener)
    • replaceReducer(nextReducer)

    關於 Store 重點是要知道 Redux 只有一個 Store 負責存放整個 App 的 State,而唯一能改變 State 的方法只有發送 action。

  3. combineReducers:combineReducers(reducers)

    combineReducers 可以將多個 reducers 進行整合並回傳一個 Function,讓我們可以將 reducer 適度分割

  4. applyMiddleware:applyMiddleware(...middlewares)

    官方針對 Middleware 進行說明

    It provides a third-party extension point between dispatching an
    action, and the moment it reaches the reducer.

    若有 NodeJS 的經驗的讀者,對於 middleware 概念應該不陌生,讓開發者可以在 req 和 res 之間進行一些操作。在 Redux 中 Middleware 則是扮演 action 到達 reducer 前的第三方擴充。而 applyMiddleware 可以將多個 middlewares 整合並回傳一個 Function,便於使用。

    若是你要使用 asynchronous(非同步)的行為的話需要使用其中一種 middleware: redux-thunkredux-promiseredux-promise-middleware ,這樣可以讓你在 actions 中 dispatch Promises 而非 function。asynchronous(非同步)運作方式就如同下圖所示:

    React Redux

  5. bindActionCreators:bindActionCreators(actionCreators, dispatch)

    bindActionCreators 可以將 actionCreatorsdispatch 綁定,並回傳一個 Function 或 Object,讓程式更簡潔。但若是使用 react-redux 可以用 connect 讓 dispatch 行為更容易管理

  6. compose:compose(...functions)

    compose 可以將 function 由右到左合併並回傳一個 Function,如官網範例所示:

    import { createStore, combineReducers, applyMiddleware, compose } from 'redux'
    import thunk from 'redux-thunk'
    import DevTools from './containers/DevTools'
    import reducer from '../reducers/index'
    
    const store = createStore(
      reducer,
      compose(
        applyMiddleware(thunk),
        DevTools.instrument()
      )
    )

23.5 總結

以上介紹了 Redux 的基礎概念,若是讀者覺得還是有點抽象的話也沒關係,在下一個章節我們將實際帶大家開發一個整合 ReactReduxImmutableJS 的 TodoApp。

23.6 延伸閱讀

  1. Redux 官方網站
  2. Redux架构实践——Single Source of Truth
  3. Presentational and Container Components
  4. 使用Redux管理你的React应用
  5. Using redux

(image via githubusercontentmakeitopencss-trickstightentryolabsfacebookJonasOhlsson

23.7 🚪 任意門

回首頁 | 上一章:Flux 基礎概念與實戰入門 | 下一章:Redux 實戰入門 |

勘誤、提問或許願 |

24 Redux 實戰入門

24.1 前言

上一節我們了解了 Redux 基本的概念和特性後,本章我們要實際動手用 Redux、React Redux 結合 ImmutableJS 開發一個簡單的 Todo 應用。話不多說,那就讓讓我們開始吧!

以下這張圖表示了整個 React Redux App 的資料流程圖(使用者與 View 互動 => dispatch 出 Action => Reducers 依據 action tyoe 分配到對應處理方式,回傳新的 state => 透過 React Redux 傳送給 React,React 重新繪製 View):

React Redux

24.2 動手創作 React Redux ImmutableJS TodoApp

在開始創作之前我們先完成一些開發的前置作業,先透過以下指令在根目錄產生 npm 設定檔 package.json

$ npm init

安裝相關套件(包含開發環境使用的套件):

$ npm install --save react react-dom redux react-redux immutable redux-actions redux-immutable
$ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react html-webpack-plugin webpack webpack-dev-server

安裝好後我們可以設計一下我們的資料夾結構,首先我們在根目錄建立 src,放置 scriptsource 。在 components 資料夾中我們會放置所有 components(個別元件資料夾中會用 index.js 輸出元件,讓引入元件更簡潔)、containers(負責和 store 互動取得 state),另外還有 actionsconstantsreducersstore,其餘設定檔則放置於根目錄下。

大致上的資料夾結構會長這樣:

React Redux

接下來我們參考上一章設定一下開發文檔(.babelrc.eslintrcwebpack.config.js)。這樣我們就完成了開發環境的設定可以開始動手實作 React Redux 應用程式了!

首先我們先用 Component 之眼感受一下我們應用程式,將它切成一個個 Component。在這邊我們設計一個主要的 Main 包含兩個子 Component:TodoHeaderTodoList

React Redux

首先設計 HTML Markup:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Redux Todo</title>
</head>
<body>
    <div id="app"></div>
</body>
</html>

在撰寫 src/index.js 之前,我們先說明整合 react-redux 的用法。從以下這張圖可以看到 react-redux 是 React 和 Redux 間的橋樑,使用 Providerconnect 去連結 store 和 React View。

React Redux

事實上,整合了 react-redux 後,我們的 React App 就可以解決傳統跨 Component 之前傳遞 state 的問題和困難。只要透過 Provider 就可以讓每個 React App 中的 Component 取用 store 中的 state,非常方便(接下來我們也會更詳細說明 Container/Component、connect 的用法)。

React Redux

以下是 src/index.js 完整程式碼:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import Main from './components/Main';
import store from './store';

ReactDOM.render(
  <Provider store={store}>
    <Main />
  </Provider>,
  document.getElementById('app')
);

其中 src/components/Main/Main.js 是 Stateless Component,負責所有 View 的進入點。

import React from 'react';
import ReactDOM from 'react-dom';
import TodoHeaderContainer from '../../containers/TodoHeaderContainer';
import TodoListContainer from '../../containers/TodoListContainer';

const Main = () => (
  <div>
    <TodoHeaderContainer />
    <TodoListContainer />
  </div>
);

export default Main;

接下來我們定義一下 Actions 的部份,由於是範例 App 所以相對簡單,這邊只定義一個 todoActions。在這邊我們使用了 redux-actions,它可以方便我們使用 Flux Standard Action 格式的 action。以下是 src/actions/todoActions.js 完整程式碼:

import { createAction } from 'redux-actions';
import {
  CREATE_TODO,
  DELETE_TODO,
  CHANGE_TEXT,
} from '../constants/actionTypes';

export const createTodo = createAction('CREATE_TODO');
export const deleteTodo = createAction('DELETE_TODO');
export const changeText = createAction('CHANGE_TEXT');

我們在 src/actions/index.js 將所有 actions 輸出

export * from './todoActions';

另外我們把 constants 放到 components 資料夾中方便管理,以下是 src/constants/actionTypes.js 程式碼:

export const CREATE_TODO = 'CREATE_TODO';
export const DELETE_TODO = 'DELETE_TODO';
export const CHANGE_TEXT = 'CHANGE_TEXT';

/* 
或是可以考慮使用 keyMirror,方便產生與 key 相同的常數
import keyMirror from 'fbjs/lib/keyMirror';

export default keyMirror({
    ADD_ITEM: null,
    DELETE_ITEM: null,
    DELETE_ALL: null,
    FILTER_ITEM: null
});
*/

設定 Actions 後我們來討論一下 Reducers 的部份。在討論 Reducers 之前我們先來設定一下我們的前端的資料結構,在這邊我們把所有資料結構(initialState)放到 src/constants/models.js 中。這邊特別注意的是由於 Redux 中有一個重要特性是 State is read-only,也就是說更新當 reducers 進到 action 只會回傳新的 state 不會更改到原有的 state。因此我們會在整個 Redux App 中使用 ImmutableJS 讓整個資料流維持在 Immutable 的狀態,也可以提昇程式開發上的效能和避免不可預期的副作用。

以下是 src/constants/models.js 完整程式碼,其設定了 TodoState 的資料結構並使用 fromJS() 轉成 Immutable

import Immutable from 'immutable';

export const TodoState = Immutable.fromJS({
  'todos': [],
  'todo': {
    id: '',
    text: '',
    updatedAt: '',
    completed: false,
  }
});

接下來我們要討論的是 Reducers 的部份,在 todoReducers 中我們會根據接收到的 action 進行 mapping 到對應的處理函式並傳入夾帶的 payload 資料(這邊我們使用 redux-actions 來進行 mapping,使用上比傳統的 switch 更為簡潔)。Reducers 接收到 action 的處理方式為 (initialState, action) => newState,最終會回傳一個新的 state,而非更改原來的 state,所以這邊我們使用 ImmutableJS

import { handleActions } from 'redux-actions';
import { TodoState } from '../../constants/models';

import {
  CREATE_TODO,
  DELETE_TODO,
  CHANGE_TEXT,
} from '../../constants/actionTypes';

 const todoReducers = handleActions({
  CREATE_TODO: (state) => {
    let todos = state.get('todos').push(state.get('todo'));
    return state.set('todos', todos)
  },
  DELETE_TODO: (state, { payload }) => (
    state.set('todos', state.get('todos').splice(payload.index, 1))
  ),
  CHANGE_TEXT: (state, { payload }) => (
    state.merge({ 'todo': payload })
  )
}, TodoState);

export default todoReducers;
import { handleActions } from 'redux-actions';
import UiState from '../../constants/models';

export default handleActions({
  SHOW: (state, { payload }) => (
    state.set('todos', payload.todo)
  ),
}, UiState); 

雖然 Redux 本身僅會有一個 store,但 redux 本身有提供了 combineReducers 可以讓我們切割我們 state 方便維護和管理。實上,state 的規劃也是一們學問,通常需要不斷地實作和工作團隊討論才能找到比較好的方式。不過這邊要注意的是我們改使用了 redux-immutablecombineReducers 這樣可以確保我們的 state 維持在 Immutable 的狀態。

由於 Redux 官方也沒有特別明確或嚴謹的規範。在一般情況我會將 reducers 分為 data 和單純和 UI 有關的 ui state。但由於這邊是比較簡單的例子,我們最終只使用到 src/reducers/data/todoReducers.js

import { combineReducers } from 'redux-immutable';
import ui from './ui/uiReducers';// import routes from './routes';
import todo from './data/todoReducers';// import routes from './routes';

const rootReducer = combineReducers({
  todo,
});

export default rootReducer;

還記得我們上面說明 React Redux 之前的橋樑時有提到的 store 嗎?現在我們要更仔細地去設計 store,我們這邊使用到了 redux 其中兩個 API:applyMiddleware、createStore。分別可以產生 store 和掛載我們要使用的 middleware(這邊我們只使用到 redux-logger 方便我們除錯)。注意我們 initialState 也是維持在 Immutable 的狀態。

import { createStore, applyMiddleware } from 'redux';
import createLogger from 'redux-logger';
import Immutable from 'immutable';
import rootReducer from '../reducers';

const initialState = Immutable.Map();

export default createStore(
  rootReducer,
  initialState,
  applyMiddleware(createLogger({ stateTransformer: state => state.toJS() }))
);

透過 src/store/index.js 輸出 configureStore:

export { default } from './configureStore';

講解完架構層面的議題,終於我們來到了 View 的部份。加油,距離我們終點也不遠了!
在開始討論 Component 的部份之前我們先來研究一下

react-redux 所提供的 API connect 將 props 傳給 Component,其用法如下:

connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])

在我們的範例 App 中我們只會先用到前兩個參數,第三個參數會在之後的例子裡用到。第一個參數 mapStateToProps 是一個讓開發者可以從 store 取出想要 state 並當做 props 往下傳的功能,第二個參數則是將 dispatch 行為封裝成函數順著 props 可以方便往下傳和呼叫。

以下是 src/components/TodoHeader/TodoHeader.js 的部份:

import React from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import TodoHeader from '../../components/TodoHeader';

// 將欲使用的 actions 引入
import {
  changeText,
  createTodo,
} from '../../actions';

const mapStateToProps = (state) => ({
    // 從 store 取得 todo state
    todo: state.getIn(['todo', 'todo'])
});

const mapDispatchToProps = (dispatch) => ({
    // 當使用者在 input 輸入資料值即會觸發這個函數,發出 changeText action 並附上使用者輸入內容 event.target.value
    onChangeText: (event) => (
      dispatch(changeText({ text: event.target.value }))
    ),
    // 當使用者按下送出時,發出 createTodo action 並清空 input 
    onCreateTodo: () => {
      dispatch(createTodo());
      dispatch(changeText({ text: '' }));
    }
});

export default connect(
    mapStateToProps,
    mapDispatchToProps,
)(TodoHeader);

// 開始建設 Component 並使用 connect 進來的 props 並綁定事件(onChange、onClick)。注意我們的 state 因為是使用 `ImmutableJS` 所以要用 `get()` 取值
const TodoHeader = ({
  onChangeText,
  onCreateTodo,
  todo,
}) => (
  <div>
    <h1>TodoHeader</h1>
    <input type="text" value={todo.get('text')} onChange={onChangeText} />
    <button onClick={onCreateTodo}>送出</button>
  </div>
);

export default TodoHeader;

以下是 src/components/TodoList/TodoList.js 的部份:

import React from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import TodoList from '../../components/TodoList';

import {
  deleteTodo,
} from '../../actions';

const mapStateToProps = (state) => ({
  todos: state.getIn(['todo', 'todos'])
});

// 由 Component 傳進欲刪除元素的 index
const mapDispatchToProps = (dispatch) => ({
  onDeleteTodo: (index) => () => (
    dispatch(deleteTodo({ index }))
  )
});

export default connect(
    mapStateToProps,
    mapDispatchToProps,
)(TodoList);

// Component 部分值的注意的是 todos state 是透過 map function 去迭代出元素,由於要讓 React JSX 可以渲染並保持傳入觸發 event state 的 immutable,所以需使用 toJS() 轉換 component of array。
const TodoList = ({
  todos,
  onDeleteTodo,
}) => (
  <div>
    <ul>
    {
      todos.map((todo, index) => (
        <li key={index}>
          {todo.get('text')}
          <button onClick={onDeleteTodo(index)}>X</button>
        </li>
      )).toJS()
    }
    </ul>
  </div>
);

export default TodoList;

若是一切順利的話就可以在瀏覽器上看到自己努力的成果囉!(因為我們有使用 redux-logger 所以打開 console 會看到 action 和 state 的變化情形,但記得在 production 環境要拿掉)

React Redux

24.3 總結

以上就是 Redux 實戰入門,對於第一次自己動手寫 Redux 的朋友可能會需要多練習幾次,多體會整個架構。在接下來的章節我們將優化我們的 React Redux TodoApp,讓它可以有更清晰好維護的架構。

24.4 延伸閱讀

  1. Redux 官方網站

(image via JonasOhlssonlicdn

24.5 🚪 任意門

回首頁 | 上一章:Redux 基礎概念 | 下一章:Container 與 Presentational Components 入門 |

勘誤、提問或許願 |

25 Ch08 Container 與 Presentational Components

  1. Container 與 Presentational Components 入門

25.1 🚪 任意門

| 回首頁 |

26 Container 與 Presentational Components 入門

26.1 前言

在聊完了 React 和 Redux 整合後我們來談談分離 Presentational 和 Container Component 的概念,若你是第一次聽過這個名詞,我建議你可以先看看 Redux 作者 Dan AbramovFollow 所寫的這篇文章 Presentational and Container Components

26.2 Container 與 Presentational Components 超級比一比

以下先參考 Redux 官網 列出兩者相異之處:

  1. Presentational Components
    • 用途:怎麼看事情(Markup、外觀)
    • 是否讓 Redux 意識到:否
    • 取得資料方式:從 props 取得
    • 改變資料方式:從 props 去呼叫 callback function
  • 寫入方式:手動處理
  1. Container Components
  • 用途:怎麼做事情(擷取資料,更新 State)
  • 是否讓 Redux 意識到:是
  • 取得資料方式:訂閱 Redux State(store)
  • 改變資料方式:Dispatch Redux Action
  • 寫入方式:從 React Redux 產生

從上面的分析讀者可以發現,兩者最大的差別在於 Component 主要負責單純的 UI 的渲染,而 Container 則負責和 Redux 的 store 溝通,作為 ReduxComponent 之間的橋樑。這樣的分法可以讓程式架構和職責更清楚,所以接下來我們就使用上一章節的 Redux TodoApp 進行改造,改造成 Container 與 Presentational Components 模式。

26.3 Container Components

以下是 src/containers/TodoHeaderContainer/TodoHeaderContainer.js 的部份:

import { connect } from 'react-redux';
import TodoHeader from '../../components/TodoHeader';

// 將欲使用的 actions 引入
import {
  changeText,
  createTodo,
} from '../../actions';

const mapStateToProps = (state) => ({
  // 從 store 取得 todo state
  todo: state.getIn(['todo', 'todo'])
});

const mapDispatchToProps = (dispatch) => ({
  // 當使用者在 input 輸入資料值即會觸發這個函數,發出 changeText action 並附上使用者輸入內容 event.target.value
  onChangeText: (event) => (
    dispatch(changeText({ text: event.target.value }))
  ),
  // 當使用者按下送出時,發出 createTodo action 並清空 input 
  onCreateTodo: () => {
    dispatch(createTodo());
    dispatch(changeText({ text: '' }));
  }
});

export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(TodoHeader);

以下是 src/containers/TodoListContainer/TodoListContainer.js 的部份:

import { connect } from 'react-redux';
import TodoList from '../../components/TodoList';

import {
  deleteTodo,
} from '../../actions';

const mapStateToProps = (state) => ({
  todos: state.getIn(['todo', 'todos'])
});

const mapDispatchToProps = (dispatch) => ({
  onDeleteTodo: (index) => () => (
    dispatch(deleteTodo({ index }))
  )
});

export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(TodoList);

26.4 Presentational Components

以下是 src/components/TodoHeader/TodoHeader.js 的部份:

import React from 'react';
import ReactDOM from 'react-dom';

// 開始建設 Component 並使用 connect 進來的 props 並綁定事件(onChange、onClick)。注意我們的 state 因為是使用 `ImmutableJS` 所以要用 `get()` 取值

const TodoHeader = ({
  onChangeText,
  onCreateTodo,
  todo,
}) => (
  <div>
    <h1>TodoHeader</h1>
    <input type="text" value={todo.get('text')} onChange={onChangeText} />
    <button onClick={onCreateTodo}>送出</button>
  </div>
);

export default TodoHeader;

以下是 src/components/TodoList/TodoList.js 的部份:

import React from 'react';
import ReactDOM from 'react-dom';

// Component 部分值的注意的是 todos state 是透過 map function 去迭代出元素,由於要讓 React JSX 可以渲染並保持傳入觸發 event state 的 immutable,所以需使用 toJS() 轉換 component of array。
// 由 Component 傳進欲刪除元素的 index

const TodoList = ({
  todos,
  onDeleteTodo,
}) => (
  <div>
    <ul>
    {
      todos.map((todo, index) => (
        <li key={index}>
          {todo.get('text')}
          <button onClick={onDeleteTodo(index)}>X</button>
        </li>
      )).toJS()
    }
    </ul>
  </div>
);

export default TodoList;

26.5 總結

That’s it!透過區分 Container 與 Presentational Components 可以讓程式架構和職責更清楚了!接下來我們將運用我們所學實際開發兩個貼近生活的專案,讓讀者更加熟悉 React 生態系如何應用於實務上。

26.6 延伸閱讀

  1. Presentational and Container Components
  2. Redux Usage with React
  3. React Higher Order Components in depth
  4. React higher order components

26.7 🚪 任意門

回首頁 | 上一章:Redux 實戰入門 | 下一章:用 React + Router + Redux + ImmutableJS 寫一個 Github 查詢應用 |

勘誤、提問或許願 |

27 Ch09 用 React + Router + Redux + ImmutableJS 寫一個 Github 查詢應用

  1. 用 React + Router + Redux + ImmutableJS 寫一個 Github 查詢應用

27.1 🚪 任意門

| 回首頁 |

28 用 React + Router + Redux + ImmutableJS 寫一個 Github 查詢應用

28.1 前言

學了一身本領後,本章將帶大家完成一個單頁式應用程式(Single Page Application),整合 React + Redux + ImmutableJS + React Router 搭配 Github API 製作一個簡單的 Github 使用者查詢應用,實際體驗一下開發 React App 的感受。

28.2 功能規劃

讓訪客可以使用 Github ID 搜尋 Github 使用者,展示 Github 使用者名稱、follower、following、avatar_url 並可以返回首頁。

28.3 使用技術

  1. React
  2. Redux
  3. Redux Thunk
  4. React Router
  5. ImmutableJS
  6. Fetch
  7. Material UI
  8. Roboto Font from Google Font
  9. Github API(https://api.github.com/users/torvalds

不過要注意的是 Github API 若沒有使用 App key 的話可以呼叫 API 的次數會受限

28.4 專案成果截圖

React Redux

React Redux

28.5 環境安裝與設定

  1. 安裝 Node 和 NPM

  2. 安裝所需套件

$ npm install --save react react-dom redux react-redux react-router immutable redux-immutable redux-actions whatwg-fetch redux-thunk material-ui react-tap-event-plugin
$ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react babel-preset-stage-1 eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react html-webpack-plugin webpack webpack-dev-server redux-logger

接下來我們先設定一下開發文檔。

  1. 設定 Babel 的設定檔: .babelrc

    {
        "presets": [
        "es2015",
        "react",
        ],
        "plugins": []
    }
  2. 設定 ESLint 的設定檔和規則: .eslintrc

    {
      "extends": "airbnb",
      "rules": {
        "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
      },
      "env" :{
        "browser": true,
      }
    }
  3. 設定 Webpack 設定檔: webpack.config.js

    // 讓你可以動態插入 bundle 好的 .js 檔到 .index.html
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    
    const HTMLWebpackPluginConfig = new HtmlWebpackPlugin({
      template: `${__dirname}/src/index.html`,
      filename: 'index.html',
      inject: 'body',
    });
    
    // entry 為進入點,output 為進行完 eslint、babel loader 轉譯後的檔案位置
    module.exports = {
      entry: [
        './src/index.js',
      ],
      output: {
        path: `${__dirname}/dist`,
        filename: 'index_bundle.js',
      },
      module: {
        preLoaders: [
          {
            test: /\.jsx$|\.js$/,
            loader: 'eslint-loader',
            include: `${__dirname}/src`,
            exclude: /bundle\.js$/
          }
        ],
        loaders: [{
          test: /\.js$/,
          exclude: /node_modules/,
          loader: 'babel-loader',
          query: {
            presets: ['es2015', 'react'],
          },
        }],
      },
      // 啟動開發測試用 server 設定(不能用在 production)
      devServer: {
        inline: true,
        port: 8008,
      },
      plugins: [HTMLWebpackPluginConfig],
    };

太好了!這樣我們就完成了開發環境的設定可以開始動手實作 Github Finder 應用程式了!

28.6 動手實作

  1. Setup Mockup

    HTML Markup(src/index.html):

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
        <title>GithubFinder</title>
        <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
    </head>
    <body>
        <div id="app"></div>
    </body>
    </html>

    設定 webpack.config.js 的進入點 src/index.js

    import React from 'react';
    import ReactDOM from 'react-dom';
    import { Provider } from 'react-redux';
    import { browserHistory, Router, Route, IndexRoute } from 'react-router';
    import injectTapEventPlugin from 'react-tap-event-plugin';
    import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
    import Main from './components/Main';
    import HomePageContainer from './containers/HomePageContainer';
    import ResultPageContainer from './containers/ResultPageContainer';
    import store from './store';
    
    // 引入 react-tap-event-plugin 避免 material-ui onTouchTap event 會遇到的問題
    // Needed for onTouchTap
    // http://stackoverflow.com/a/34015469/988941
    injectTapEventPlugin();
    
    // 用 react-redux 的 Provider 包起來將 store 傳遞下去,讓每個 components 都可以存取到 state
    // 這邊使用 browserHistory 當做 history,並使用 material-ui 的 MuiThemeProvider 包裹整個 components
    // 由於這邊是簡易的 App 我們設計了 Main 為母模版,其有兩個子元件 HomePageContainer 和 ResultPageContainer,其中 HomePageContainer 為根位置的子元件
    ReactDOM.render(
      <Provider store={store}>
        <MuiThemeProvider>
          <Router history={browserHistory}>
            <Route path="/" component={Main}>
              <IndexRoute component={HomePageContainer} />
              <Route path="/result" component={ResultPageContainer} />
            </Route>
          </Router>
        </MuiThemeProvider>
      </Provider>,
      document.getElementById('app')
    );
  2. Actions

    首先先定義 actions 常數:

    export const SHOW_SPINNER = 'SHOW_SPINNER';
    export const HIDE_SPINNER = 'HIDE_SPINNER';
    export const GET_GITHUB_INITIATE = 'GET_GITHUB_INITIATE';
    export const GET_GITHUB_SUCCESS = 'GET_GITHUB_SUCCESS';
    export const GET_GITHUB_FAIL = 'GET_GITHUB_FAIL';
    export const CHAGE_USER_ID = 'CHAGE_USER_ID';

    現在我們來規劃我們的 actions 的部份,這個範例我們使用到了 redux-thunk 來處理非同步的 action(若讀者對於新的 Ajax 處理方式 fetch() 不熟悉可以先參考這個文件)。以下是 src/actions/githubActions.js 完整程式碼:

    // 這邊引入了 fetch 的 polyfill,考以讓舊的瀏覽器也可以使用 fetch
    import 'whatwg-fetch';
    // 引入 actionTypes 常數
    import {
      GET_GITHUB_INITIATE,
      GET_GITHUB_SUCCESS,
      GET_GITHUB_FAIL,
      CHAGE_USER_ID,
    } from '../constants/actionTypes';
    
    // 引入 uiActions 的 action
    import {
      showSpinner,
      hideSpinner,
    } from './uiActions';
    
    // 這邊是這個範例的重點,要學習我們之前尚未講解的非同步 action 處理方式:不同於一般同步 action 直接發送 action,非同步 action 會回傳一個帶有 dispatch 參數的 function,裡面使用了 Ajax(這裡使用 fetch())進行處理
    // 一般和 API 互動的流程:INIT(開始請求/秀出 spinner)-> COMPLETE(完成請求/隱藏 spinner)-> ERROR(請求失敗)
    // 這次我們雖然沒有使用 redux-actions 但我們還是維持標準 Flux Standard Action 格式:{ type: '', payload: {} }
    
    export const getGithub = (userId = 'torvalds') => {
      return (dispatch) => {
        dispatch({ type: GET_GITHUB_INITIATE });
        dispatch(showSpinner());
        fetch('https://api.github.com/users/' + userId)
          .then(function(response) { return response.json() })
          .then(function(json) { 
            dispatch({ type: GET_GITHUB_SUCCESS, payload: { data: json } });
            dispatch(hideSpinner());
          })
          .catch(function(response) { dispatch({ type: GET_GITHUB_FAIL }) });
      } 
    }
    
    // 同步 actions 處理,回傳 action 物件
    export const changeUserId = (text) => ({ type: CHAGE_USER_ID, payload: { userId: text } });

    以下是 src/actions/uiActions.js 負責處理 UI 的行為:

    import { createAction } from 'redux-actions';
    import {
      SHOW_SPINNER,
      HIDE_SPINNER,
    } from '../constants/actionTypes';
    
    // 同步 actions 處理,回傳 action 物件
    export const showSpinner = () => ({ type: SHOW_SPINNER});
    export const hideSpinner = () => ({ type: HIDE_SPINNER});

    透過於 src/actions/index.js 將我們 actions 輸出

    export * from './uiActions';
    export * from './githubActions';
  3. Reducers

    接下來我們要來設定一下 Reducers 和 models(initialState 格式)的設計,注意我們這個範例都是使用 ImmutableJS。以下是 src/constants/models.js

    import Immutable from 'immutable';
    
    export const UiState = Immutable.fromJS({
      spinnerVisible: false,
    });
    
    // 我們使用 userId 來暫存使用者 ID,data 存放 Ajax 取回的資料
    export const GithubState = Immutable.fromJS({
      userId: '',
      data: {},
    });

    以下是 src/reducers/data/githubReducers.js

    import { handleActions } from 'redux-actions';
    import { GithubState } from '../../constants/models';
    
    import {
      GET_GITHUB_INITIATE,
      GET_GITHUB_SUCCESS,
      GET_GITHUB_FAIL,
      CHAGE_USER_ID,
    } from '../../constants/actionTypes';
    
    const githubReducers = handleActions({ 
      // 當使用者按送出按鈕,發出 GET_GITHUB_SUCCESS action 時將接收到的資料 merge 
      GET_GITHUB_SUCCESS: (state, { payload }) => (
        state.merge({
          data: payload.data,
        })
      ),  
      // 當使用者輸入使用者 ID 會發出 CHAGE_USER_ID action 時將接收到的資料 merge 
      CHAGE_USER_ID: (state, { payload }) => (
        state.merge({
          'userId':
          payload.userId
        })
      ),
    }, GithubState);
    
    export default githubReducers;

    以下是 src/reducers/ui/uiReducers.js

    import { handleActions } from 'redux-actions';
    import { UiState } from '../../constants/models';
    
    import {
      SHOW_SPINNER,
      HIDE_SPINNER,
    } from '../../constants/actionTypes';
    
    // 隨著 fetch 結果顯示 spinner
    const uiReducers = handleActions({
      SHOW_SPINNER: (state) => (
        state.set(
          'spinnerVisible',
          true
        )
      ),
      HIDE_SPINNER: (state) => (
        state.set(
          'spinnerVisible',
          false
        )
      ),
    }, UiState);
    
    export default uiReducers;

    將 reduces 使用 redux-immutablecombineReducers 在一起。以下是 src/reducers/index.js

    import { combineReducers } from 'redux-immutable';
    import ui from './ui/uiReducers';// import routes from './routes';
    import github from './data/githubReducers';// import routes from './routes';
    
    const rootReducer = combineReducers({
      ui,
      github,
    });
    
    export default rootReducer;

    運用 redux 提供的 createStore API 把 rootReducerinitialStatemiddlewares 整合後創建出 store。以下是 src/store/configureSotore.js

    import { createStore, applyMiddleware } from 'redux';
    import reduxThunk from 'redux-thunk';
    import createLogger from 'redux-logger';
    import Immutable from 'immutable';
    import rootReducer from '../reducers';
    
    const initialState = Immutable.Map();
    
    export default createStore(
      rootReducer,
      initialState,
      applyMiddleware(reduxThunk, createLogger({ stateTransformer: state => state.toJS() }))
    );
  4. Build Component

    終於我們進入了 View 的細節設計,首先我們先針對母模版,也就是每個頁面都會出現的 AppBar 做設計。以下是 src/components/Main/Main.js

    import React from 'react';
    // 引入 AppBar
    import AppBar from 'material-ui/AppBar';
    
    const Main = (props) => (
      <div>
        <AppBar
          title="Github Finder"
          showMenuIconButton={false}
        />
        <div>
          {props.children}
        </div>
      </div>
    );
    
    // 進行 propTypes 驗證
    Main.propTypes = {
      children: React.PropTypes.object,
    };
    
    export default Main;

    以下是 src/components/ResultPage/HomePage.js

    import React from 'react';
    // 使用 react-router 的 Link 當做超連結,傳送 userId 當作 query
    import { Link } from 'react-router';
    import RaisedButton from 'material-ui/RaisedButton';
    import TextField from 'material-ui/TextField';
    import IconButton from 'material-ui/IconButton';
    import FontIcon from 'material-ui/FontIcon';
    
    const HomePage = ({
      userId,
      onSubmitUserId,
      onChangeUserId,
    }) => (
      <div>
        <TextField
          hintText="Please Key in your Github User Id."
          onChange={onChangeUserId}
        />
        <Link to={{ 
          pathname: '/result',
          query: { userId: userId }
        }}>
          <RaisedButton label="Submit" onClick={onSubmitUserId(userId)} primary />
        </Link>
      </div>
    );
    
    export default HomePage;

    以下是 src/components/ResultPage/ResultPage.js,將 userId 當作 props 傳給 <GithubBox />

    import React from 'react';
    import GithubBox from '../../components/GithubBox';
    
    const ResultPage = (props) => (
      <div> 
        <GithubBox data={props.data} userId={props.location.query.userId} />  
      </div>
    );
    
    export default ResultPage;

    以下是 src/components/GithubBox/GithubBox.js,負責擷取的 Github 資料呈現:

    import React from 'react';
    import { Link } from 'react-router';
    // 引入 material-ui 的卡片式元件
    import { Card, CardActions, CardHeader, CardMedia, CardTitle, CardText } from 'material-ui/Card';
    // 引入 material-ui 的 RaisedButton
    import RaisedButton from 'material-ui/RaisedButton';
    // 引入 ActionHome icon
    import ActionHome from 'material-ui/svg-icons/action/home';
    
    const GithubBox = (props) => (
      <div>
        <Card>
          <CardHeader
            title={props.data.get('name')}
            subtitle={props.userId}
            avatar={props.data.get('avatar_url')}
          />
          <CardText>
            Followers : {props.data.get('followers')}
          </CardText>      
          <CardText>
            Following : {props.data.get('following')}
          </CardText>
          <CardActions>
            <Link to="/">
              <RaisedButton 
                label="Back" 
                icon={<ActionHome />}
                secondary={true} 
              />
            </Link>
          </CardActions>
        </Card> 
      </div>
    );
    
    export default GithubBox;
  5. Connect State to Component

    最後,我們要將 Container 和 Component 連接在一起(若忘記了,請先回去複習 Container 與 Presentational Components 入門!)。以下是 src/containers/HomePage/HomePage.js,負責將 userId 和使用到的事件處理方法用 props 傳進 component :

    import { connect } from 'react-redux';
    import HomePage from '../../components/HomePage';
    
    import {
      getGithub,
      changeUserId,
    } from '../../actions';
    
    export default connect(
      (state) => ({
        userId: state.getIn(['github', 'userId']),
      }),
      (dispatch) => ({
        onChangeUserId: (event) => (
          dispatch(changeUserId(event.target.value))
        ),
        onSubmitUserId: (userId) => () => (
          dispatch(getGithub(userId))
        ),
      }),
      (stateProps, dispatchProps, ownProps) => {
        const { userId } = stateProps;
        const { onSubmitUserId } = dispatchProps;
        return Object.assign({}, stateProps, dispatchProps, ownProps, {
          onSubmitUserId: onSubmitUserId(userId),
        });
      }
    )(HomePage);

    以下是 src/containers/ResultPage/ResultPage.js

    import { connect } from 'react-redux';
    import ResultPage from '../../components/ResultPage';
    
    export default connect(
      (state) => ({
        data: state.getIn(['github', 'data'])    
      }),
      (dispatch) => ({})
    )(ResultPage);
  6. That’s it

    若一切順利的話,這時候你可以在終端機下 $ npm start 指令,然後在 http://localhost:8008 就可以看到你的努力成果囉!

    React Redux

28.7 總結

本章帶領讀者們從零開始整合 React + Redux + ImmutableJS + React Router 搭配 Github API 製作一個簡單的 Github 使用者查詢應用。下一章我們將挑戰進階應用,學習 Server Side Rendering 方面的知識,並用 React + Redux + Node(Isomorphic)開發一個食譜分享網站。

28.8 延伸閱讀

  1. Tutorial: build a weather app with React
  2. OpenWeatherMap
  3. Weather Icons
  4. Weather API Icons
  5. Material UI
  6. 【翻译】这个API很“迷人”——(新的Fetch API)
  7. Redux: trigger async data fetch on React view event
  8. Github API
  9. 传统 Ajax 已死,Fetch 永生

28.9 🚪 任意門

回首頁 | 上一章:Container 與 Presentational Components 入門 | 下一章:React Redux Sever Rendering(Isomorphic JavaScript)入門 |

勘誤、提問或許願 |

29 Ch10 實戰教學:用 React + Redux + Node(Isomorphic JavaScript)開發食譜分享網站

  1. React Redux Sever Rendering(Isomorphic JavaScript)入門
  2. 用 React + Redux + Node(Isomorphic JavaScript)開發一個食譜分享網站

29.1 🚪 任意門

| 回首頁 |

30 React Redux Sever Rendering(Isomorphic JavaScript)入門

React Redux Sever Rendering(Isomorphic)入門

30.1 前言

由於可能有些讀者沒聽過 Isomorphic JavaScript 。因此在進到開發 React Redux Sever Rendering 應用程式的主題之前我們先來聊聊 Isomorphic JavaScript 這個議題。

根據 Isomorphic JavaScript 這個網站的說明:

Isomorphic JavaScript
Isomorphic JavaScript apps are JavaScript applications that can run both client-side and server-side.
The backend and frontend share the same code.

Isomorphic JavaScript 係指瀏覽器端和伺服器端共用 JavaScript 的程式碼。

另外,除了 Isomorphic JavaScript 外,讀者或許也有聽過 Universal JavaScript 這個用詞。那什麼是 Universal JavaScript 呢?它和 Isomorphic JavaScript 是指一樣的意思嗎?針對這個議題網路上有些開發者提出了自己的觀點: Universal JavaScriptIsomorphism vs Universal JavaScript。其中 Isomorphism vs Universal JavaScript 這篇文章的作者 Gert Hengeveld 指出 Isomorphic JavaScript 主要是指前後端共用 JavaScript 的開發方式,而 Universal JavaScript 是指 JavaScript 程式碼可以在不同環境下運行,這當然包含瀏覽器端和伺服器端,甚至其他環境。也就是說 Universal JavaScript 在意義上可以涵蓋的比 Isomorphic JavaScript 更廣泛一些,然而在 Github 或是許多技術討論上通常會把兩者視為同一件事情,這部份也請讀者留意。

30.2 Isomorphic JavaScript 的好處

在開始真正撰寫 Isomorphic JavaScript 前我們在進一步探討使用 Isomorphic JavaScript 有哪些好處?在談好處之前,我們先看看最早 Web 開發是如何處理頁面渲染和 state 管理,還有遇到哪些挑戰。

最早的時候我們談論 Web 很單純,都是由 Server 端進行模版的處理,你可以想成 template 是一個函數,我們傳送資料進去,template 最後產生一張 HTML 給瀏覽器顯示。例如:Node 使用的(EJSJade)、Python/Django 的 Template 或替代方案 Jinja、PHP 的 SmartyLaravel 使用的 Blade,甚至是 Ruby on Rails 用的 ERB。都是由後端去 render 所有資料和頁面,前端處理相對單純。

然而隨著前端工程的軟體工程化和使用者體驗的要求,開始出現各式前端框架的百花齊放,例如:Backbone.jsEmber.jsAngular.js 等前端 MVC (Model-View-Controller) 或 MVVM (Model-View-ViewModel) 框架,將頁面於前端渲染的不刷頁單頁式應用程式(Single Page App)也因此開始流行。

後端除了提供初始的 HTML 外,還提供 API Server 讓前端框架可以取得資料用於前端 template。複雜的邏輯由 ViewModel/Presenter 來處理,前端 template 只處理簡單的是否顯示或是元素迭代的狀況,如下圖所示:

React Redux Sever Rendering(Isomorphic)入門

然而前端渲染 template 雖然有它的好處但也遇到一些問題包括效能、SEO 等議題。此時我們就開始思考 Isomorphic JavaScript 的可能性:為什麼我們不能前後端都使用 JavaScript 甚至是 React?

React Redux Sever Rendering(Isomorphic)入門

事實上,React 的優勢就在於它可以很優雅地實現 Server Side Rendering 達到 Isomorphic JavaScript 的效果。在 react-dom/server 中有兩個方法 renderToStringrenderToStaticMarkup 可以在 server 端渲染你的 components。其主要都是將 React Component 在 Server 端轉成 DOM String,也可以將 props 往下傳,然而事件處理會失效,要到 client-side 的 React 接收到後才會把它加上去(但要注意 server-side 和 client-side 的 checksum 要一致不然會出現錯誤),這樣一來可以提高渲染速度和 SEO 效果。renderToStringrenderToStaticMarkup 最大的差異在於 renderToStaticMarkup 會少加一些 React 內部使用的 DOM 屬性,例如:data-react-id,因此可以節省一些資源。

使用 renderToString 進行 Server 端渲染:

import ReactDOMServer from 'react-dom/server';

ReactDOMServer.renderToString(<HelloButton name="Mark" />);

渲染出來的效果:

<button data-reactid=".7" data-react-checksum="762752829">
  Hello, Mark
</button>

總的來說使用 Isomorphic JavaScript 會有以下的好處:

  1. 有助於 SEO
  2. Rendering 速度較快,效能較佳
  3. 放棄蹩腳的 Template 語法擁抱 Component 元件化思考,便於維護
  4. 盡量前後端共用程式碼節省開發時間

不過要注意的是如果有使用 Redux 在 Server Side Rendering 中,其流程相對複雜,不過大致流程如下:
由後端預先載入需要的 initialState,由於 Server 渲染必須全部都轉成 string,所以先將 state 先 dehydration(脫水),等到 client 端再 rehydration(覆水),重建 store 往下傳到前端的 React Component。

而要把資料從伺服器端傳遞到客戶端,我們需要:

  1. 把取得初始 state 當做參數並對每個請求建立一個全新的 Redux store 實體
  2. 選擇性地 dispatch 一些 action
  3. 把 state 從 store 取出來
  4. 把 state 一起傳到客戶端

接下來我們就開始動手實作一個簡單的 React Server Side Rendering Counter 應用程式。

30.3 專案成果截圖

React Redux Sever Rendering(Isomorphic)入門

30.4 環境安裝與設定

  1. 安裝 Node 和 NPM

  2. 安裝所需套件

$ npm install --save react react-dom redux react-redux react-router immutable redux-immutable redux-actions redux-thunk babel-polyfill babel-register body-parser express morgan qs

$ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react babel-preset-stage-1 eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react html-webpack-plugin webpack webpack-dev-server redux-logger

接下來我們先設定一下開發文檔。

  1. 設定 Babel 的設定檔: .babelrc

javascript { "presets": [ "es2015", "react", ], "plugins": [] }

  1. 設定 ESLint 的設定檔和規則: .eslintrc

javascript { "extends": "airbnb", "rules": { "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], }, "env" :{ "browser": true, } }

  1. 設定 Webpack 設定檔: webpack.config.js

```javascript
// 讓你可以動態插入 bundle 好的 .js 檔到 .index.html
const HtmlWebpackPlugin = require(‘html-webpack-plugin’);

const HTMLWebpackPluginConfig = new HtmlWebpackPlugin({
template: ${__dirname}/src/index.html,
filename: ‘index.html’,
inject: ‘body’,
});

// entry 為進入點,output 為進行完 eslint、babel loader 轉譯後的檔案位置
module.exports = {
entry: [
‘./src/index.js’,
],
output: {
path: ${__dirname}/dist,
filename: ‘index_bundle.js’,
},
module: {
preLoaders: [
{
test: /.jsx$|.js$/,
loader: ‘eslint-loader’,
include: ${__dirname}/src,
exclude: /bundle.js$/
}
],
loaders: [{
test: /.js$/,
exclude: /node_modules/,
loader: ‘babel-loader’,
query: {
presets: [‘es2015’, ‘react’],
},
}],
},
// 啟動開發測試用 server 設定(不能用在 production)
devServer: {
inline: true,
port: 8008,
},
plugins: [HTMLWebpackPluginConfig],
};
```

太好了!這樣我們就完成了開發環境的設定可以開始動手實作 React Server Side Rendering Counter 應用程式了!

先看一下我們整個專案的資料結構,我們把整個專案分成三個主要的資料夾(clientserver,還有共用程式碼的 common):

React Redux Sever Rendering(Isomorphic)入門

30.5 動手實作

首先,我們先定義了 clientindex.js

// 引用 babel-polyfill 避免瀏覽器不支援部分 ES6 用法
import 'babel-polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import CounterContainer from '../common/containers/CounterContainer';
import configureStore from '../common/store/configureStore'
import { fromJS } from 'immutable';

// 從 server 取得傳進來的 initialState。由於從字串轉回物件,又稱為 rehydration(覆水) 
const initialState = window.__PRELOADED_STATE__;

// 由於我們使用 ImmutableJS,所以需要把在 server-side dehydration(脫水)又在前端 rehydration(覆水)的 initialState 轉成 ImmutableJS 資料型態,並傳進 configureStore 建立 store
const store = configureStore(fromJS(initialState));

// 接下來就跟一般的 React App 一樣,把 store 透過 Provider 往下傳到 Component 中
ReactDOM.render(
  <Provider store={store}>
    <CounterContainer />
  </Provider>,
  document.getElementById('app')
);

由於 Node 端要到新版對於 ES6 支援較好,所以先用 babel-registersrc/server/index.js 去即時轉譯 server.js,但目前不建議在 production 環境使用。

// use babel-register to precompile ES6 syntax
require('babel-register');
require('./server');

接著是我們 server 端,也是這個範例最重要的一個部分。首先我們用 express 建立了一個 port 為 3000 的 server,並使用 webpack 去執行 client 的程式碼。這個範例中我們使用了 handleRender 當 request 進來時(直接拜訪頁面或重新整理)就會執行 fetchCounter() 進行處理:

import Express from 'express';
import qs from 'qs';

import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackHotMiddleware from 'webpack-hot-middleware';
import webpackConfig from '../webpack.config';

import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import { fromJS } from 'immutable';

import configureStore from '../common/store/configureStore';
import CounterContainer from '../common/containers/CounterContainer';

import { fetchCounter } from '../common/api/counter';

const app = new Express();
const port = 3000;

function handleRender(req, res) {
  // 模仿實際非同步 api 處理情形
  fetchCounter(apiResult => {
  // 讀取 api 提供的資料(這邊我們 api 是用 setTimeout 進行模仿非同步狀況),若網址參數有值擇取值,若無則使用 api 提供的隨機值,若都沒有則取 0
    const params = qs.parse(req.query);
    const counter = parseInt(params.counter, 10) || apiResult || 0;
    // 將 initialState 轉成 immutable 和符合 state 設計的格式 
    const initialState = fromJS({
      counterReducers: {
        count: counter,
      }
    });
    // 建立一個 redux store
    const store = configureStore(initialState);
    // 使用 renderToString 將 component 轉為 string
    const html = renderToString(
      <Provider store={store}>
        <CounterContainer />
      </Provider>
    );
    // 從建立的 redux store 中取得 initialState
    const finalState = store.getState();
    // 將 HTML 和 initialState 傳到 client-side
    res.send(renderFullPage(html, finalState));
  })
}

// HTML Markup,同時也把 preloadedState 轉成字串(stringify)傳到 client-side,又稱為 dehydration(脫水)
function renderFullPage(html, preloadedState) {
  return `
    <!doctype html>
    <html>
      <head>
        <title>Redux Universal Example</title>
      </head>
      <body>
        <div id="app">${html}</div>
        <script>
          window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\x3c')}
        </script>
        <script src="/static/bundle.js"></script>
      </body>
    </html>
    `
}

// 使用 middleware 於 webpack 去進行 hot module reloading 
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: webpackConfig.output.publicPath }));
app.use(webpackHotMiddleware(compiler));
// 每次 server 接到 request 都會呼叫 handleRender
app.use(handleRender);

// 監聽 server 狀況
app.listen(port, (error) => {
  if (error) {
    console.error(error)
  } else {
    console.info(`==> 🌎  Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`)
  }
});

處理完 Server 的部份接下來我們來處理 actions 的部份,在這個範例中 actions 相對簡單,主要就是新增和減少兩個行為,以下為 src/actions/counterActions.js

import { createAction } from 'redux-actions';
import {
  INCREMENT_COUNT,
  DECREMENT_COUNT,
} from '../constants/actionTypes';

export const incrementCount = createAction(INCREMENT_COUNT);
export const decrementCount = createAction(DECREMENT_COUNT);

以下為輸出常數 src/constants/actionTypes.js

export const INCREMENT_COUNT = 'INCREMENT_COUNT';  
export const DECREMENT_COUNT = 'DECREMENT_COUNT';  

在這個範例中我們使用 setTimeout() 來模擬非同步的產生資料讓 server 端在每次接收 request 時讀取隨機產生的值。實務上,我們會開 API 讓 Server 讀取初始要匯入的 initialState。

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min)) + min
}

export function fetchCounter(callback) {
  setTimeout(() => {
    callback(getRandomInt(1, 100))
  }, 500)
}

談完 actions 我們來看我們的 reducers,在這個範例中 reducers 也是相對簡單的,主要就是針對新增和減少兩個行為去 set 值,以下是 src/reducers/counterReducers.js

import { fromJS } from 'immutable';
import { handleActions } from 'redux-actions';
import { CounterState } from '../constants/models';

import {
  INCREMENT_COUNT,
  DECREMENT_COUNT,
} from '../constants/actionTypes';

const counterReducers = handleActions({
  INCREMENT_COUNT: (state) => (
    state.set(
      'count',
      state.get('count') + 1
    )
  ),
  DECREMENT_COUNT: (state) => (
    state.set(
      'count',
      state.get('count') - 1
    )
  ),
}, CounterState);

export default counterReducers;

準備好了 rootReducer 就可以使用 createStore 來創建我們 store,值得注意的是由於 configureStore 需要被 client-side 和 server-side 使用,所以把它輸出成 function 方便傳入 initialState 使用。以下是 src/store/configureStore.js

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';
import rootReducer from '../reducers';

export default function configureStore(preloadedState) {
  const store = createStore(
    rootReducer,
    preloadedState,
    applyMiddleware(createLogger({ stateTransformer: state => state.toJS() }), thunk)
  )
  return store
}

最後來到了 componentscontainers 的時間,這次我們的 Component 主要有兩個按鈕讓使用者可以新增和減少數字並顯示目前數字。以下是 src/components/Counter/Counter.js

import React, { Component, PropTypes } from 'react'

const Counter = ({
  count,
  onIncrement,
  onDecrement,
}) => (
  <p>
    Clicked: {count} times
    {' '}
    <button onClick={onIncrement}>
      +
    </button>
    {' '}
    <button onClick={onDecrement}>
      -
    </button>
    {' '}
  </p>
);

// 注意要檢查 propTypes 和給定預設值
Counter.propTypes = {
  count: PropTypes.number.isRequired,
  onIncrement: PropTypes.func.isRequired,
  onDecrement: PropTypes.func.isRequired
}

Counter.defaultProps = {
  count: 0,
  onIncrement: () => {},
  onDecrement: () => {}
}

export default Counter;

最後把取出的 count 和事件處理方法用 connect 傳到 Counter 就大功告成了!以下是 src/containers/CounterContainer/CounterContainer.js

import 'babel-polyfill';
import { connect } from 'react-redux';
import Counter from '../../components/Counter';

import {
  incrementCount,
  decrementCount,
} from '../../actions';

export default connect(
  (state) => ({
    count: state.get('counterReducers').get('count'),
  }),
  (dispatch) => ({ 
    onIncrement: () => (
      dispatch(incrementCount())
    ),
    onDecrement: () => (
      dispatch(decrementCount())
    ),
  })
)(Counter);

若一切順利,在終端機打上 $ npm start,你將可以在瀏覽器的 http://localhost:3000 看到自己的成果!

React Redux Sever Rendering(Isomorphic)入門

30.6 總結

本章闡述了 Web 頁面瀏覽的進程和 Isomorphic JavaScript 的優勢,並介紹了如何使用 React Redux 進行 Server Side Rendering 的應用程式設計。下一個章節我們將整合後端資料庫,運用 React + Redux + Node(Isomorphic)開發一個簡單的食譜分享網站。

30.7 延伸閱讀

  1. DavidWells/isomorphic-react-example
  2. RickWong/react-isomorphic-starterkit
  3. Server-rendered React components in Rails
  4. Our First Node.js App: Backbone on the Client and Server
  5. Going Isomorphic with React
  6. A service for server-side rendering your JavaScript views
  7. Isomorphic JavaScript: The Future of Web Apps
  8. React Router Server Rendering

(image via airbnb

30.8 🚪 任意門

回首頁 | 上一章:用 React + Router + Redux + ImmutableJS 寫一個 Github 查詢應用 | 下一章:用 React + Redux + Node(Isomorphic JavaScript)開發食譜分享網站 |

勘誤、提問或許願 |

31 用 React + Redux + Node(Isomorphic JavaScript)開發食譜分享網站

31.1 前言

如果你是從一開始跟著我們踏出 React 旅程的讀者真的恭喜你,也謝謝你一路跟著我們的學習腳步,對一個初學者來說這一段路並不容易。本章是扣除附錄外我們最後一個正式章節的範例,也是規模最大的一個,在這個章節中我們要整合過去所學和添加一些知識開發一個可以登入會員並分享食譜的社群網站,Les’s GO!

31.2 需求規劃

讓使用者可以登入會員並分享食譜的社群網站

31.3 功能規劃

  1. React Router / Redux / Immutable / Server Render / Async API
  2. 使用者登入/登出(JSON Web Token)
  3. CRUD 表單資料處理
  4. 資料庫串接(ORM/MongoDB)

31.4 使用技術

  1. React
  2. Redux(redux-actions/redux-promise/redux-immutable)
  3. React Router
  4. ImmutableJS
  5. Node MongoDB ORM(Mongoose)
  6. JSON Web Token
  7. React Bootstrap
  8. Axios(Promise)
  9. Webpack
  10. UUID

31.5 專案成果截圖

用 React + Redux + Node(Isomorphic)開發一個食譜分享網站

用 React + Redux + Node(Isomorphic)開發一個食譜分享網站

用 React + Redux + Node(Isomorphic)開發一個食譜分享網站

用 React + Redux + Node(Isomorphic)開發一個食譜分享網站

31.6 環境安裝與設定

  1. 安裝 Node 和 NPM

  2. 安裝所需套件

$ npm install --save react react-dom redux react-redux react-router immutable redux-immutable redux-actions redux-promise bcrypt body-parser cookie-parser debug express immutable jsonwebtoken mongoose morgan passport passport-local react-router-bootstrap axios serve-favicon validator uuid
$ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react babel-preset-stage-1 eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react html-webpack-plugin webpack webpack-dev-server redux-logger

接下來我們先設定一下開發文檔。

  1. 設定 Babel 的設定檔: .babelrc

    {
        "presets": [
        "es2015",
        "react",
        ],
        "plugins": []
    }
  2. 設定 ESLint 的設定檔和規則: .eslintrc

    {
      "extends": "airbnb",
      "rules": {
        "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
      },
      "env" :{
        "browser": true,
      }
    }
  3. 設定 Webpack 設定檔: webpack.config.js

    import webpack from 'webpack';
    
    module.exports = {
      entry: [
        './src/client/index.js',
      ],
      output: {
        path: `${__dirname}/dist`,
        filename: 'bundle.js',
        publicPath: '/static/'
      },
      module: {
        preLoaders: [
          {
            test: /\.jsx$|\.js$/,
            loader: 'eslint-loader',
            include: `${__dirname}/app`,
            exclude: /bundle\.js$/,
          },
        ],
        // 使用 Hot Module Replacement 外掛
        plugins: [
          new webpack.optimize.OccurrenceOrderPlugin(),
          new webpack.HotModuleReplacementPlugin()
        ],    
        loaders: [{
          test: /\.js$/,
          exclude: /node_modules/,
          loader: 'babel-loader',
          query: {
            presets: ['es2015', 'react'],
          },
        }],
      },
    };
  4. 設定 src/server/config/index.js

export default ({
  "secret": "ilovecooking",
    "database": "mongodb://localhost/open_cook"
});

太好了!這樣我們就完成了開發環境的設定可以開始動手實作我們的食譜分享社群應用程式了!

同時我們也初步設計我們資料夾結構,主要我們將資料夾分為 clientcommonserver

用 React + Redux + Node(Isomorphic)開發一個食譜分享網站

31.7 動手實作

首先我們先進行 src/client/index.js 的設計:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { browserHistory, Router } from 'react-router';
import { fromJS } from 'immutable';
// 我們的 routing 放置在 common 資料夾中的 routes
import routes from '../common/routes';
import configureStore from '../common/store/configureStore';
import { checkAuth } from '../common/actions';

// 將 server side 傳過來的 initialState 給 rehydration(覆水)
const initialState = window.__PRELOADED_STATE__;

// 將 initialState 傳給 configureStore 函數創建出 store 並傳給 Provider
const store = configureStore(fromJS(initialState));
ReactDOM.render(
  <Provider store={store}>
    <Router history={browserHistory} routes={routes} />
  </Provider>,
  document.getElementById('app')
);

由於 Node 端要到新版對於 ES6 支援較好,所以先用 babel-registersrc/server/index.js 去即時轉譯 server.js,但不建議在 production 環境使用。

// use babel-register to precompile ES6 
require('babel-register');
require('./server');
// 引入 Express、mongoose(MongoDB ORM)以及相關 server 上使用的套件
/* Server Packages */
import Express from 'express';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import morgan from 'morgan';
import mongoose from 'mongoose';
import config from './config';
// 引入後端 model 透過 model 和資料庫互動 
import User from './models/user';
import Recipe from './models/recipe';

// 引入 webpackDevMiddleware 當做前端 server middleware
/* Client Packages */
import webpack from 'webpack';
import React from 'react';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackHotMiddleware from 'webpack-hot-middleware';
import { RouterContext, match } from 'react-router';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import Immutable, { fromJS } from 'immutable';
/* Common Packages */
import webpackConfig from '../../webpack.config';
import routes from '../common/routes';
import configureStore from '../common/store/configureStore';
import fetchComponentData from '../common/utils/fetchComponentData';
import apiRoutes from './controllers/api.js';
/* config */
// 初始化 Express server
const app = new Express();
const port = process.env.PORT || 3000;
// 連接到資料庫,相關設定檔案放在 config.database
mongoose.connect(config.database); // connect to database
app.set('env', 'production');
// 設定靜態檔案位置
app.use('/static', Express.static(__dirname + '/public'));
app.use(cookieParser());
// use body parser so we can get info from POST and/or URL parameters
app.use(bodyParser.urlencoded({ extended: false })); // only can deal with key/value
app.use(bodyParser.json());
// use morgan to log requests to the console
app.use(morgan('dev'));

// 負責每次接受到 request 的處理函數,判斷該如何處理和取得 initialState 整理後結合伺服器渲染頁面傳往前端
const handleRender = (req, res) => {
  // Query our mock API asynchronously
  match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
    if (error) {
      res.status(500).send(error.message);
    } else if (redirectLocation) {
      res.redirect(302, redirectLocation.pathname + redirectLocation.search);
    } else if (renderProps == null) {
      res.status(404).send('Not found');
    }
    fetchComponentData(req.cookies.token).then((response) => {
      let isAuthorized = false;
      if (response[1].data.success === true) {
         isAuthorized = true;
      } else {
        isAuthorized = false;        
      }
      const initialState = fromJS({
        recipe: {
          recipes: response[0].data,
          recipe: {
            id: '',
            name: '', 
            description: '', 
            imagePath: '',            
          }  
        },
        user: {
          isAuthorized: isAuthorized,
          isEdit: false,
        }
      });
      // server side 渲染頁面
      // Create a new Redux store instance
      const store = configureStore(initialState);
      const initView = renderToString(
        <Provider store={store}>
          <RouterContext {...renderProps} />
        </Provider>
      );
      let state = store.getState();
      let page = renderFullPage(initView, state);
      return res.status(200).send(page);
    })
    .catch(err => res.end(err.message));
  })
}

// 基礎頁面 HTML 設計
const renderFullPage = (html, preloadedState) => (`
    <!doctype html>
    <html>
      <head>
        <title>OpenCook 分享料理的美好時光</title>
        <!-- Latest compiled and minified CSS -->
        <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/latest/css/bootstrap.min.css">
        <!-- Optional theme -->
        <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/latest/css/bootstrap-theme.min.css">
        <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootswatch/3.3.7/journal/bootstrap.min.css">
      <body>
        <div id="app">${html}</div>
        <script>
          window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\x3c')}
        </script>
        <script src="/static/bundle.js"></script>
      </body>
    </html>`
);

// 設定 hot reload middleware
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: webpackConfig.output.publicPath }));
app.use(webpackHotMiddleware(compiler));

// 設計 API prefix,並使用 controller 中的 apiRoutes 進行處理
app.use('/api', apiRoutes);  
// 使用伺服器端 handleRender 
app.use(handleRender);
app.listen(port, (error) => {
  if (error) {
    console.error(error)
  } else {
    console.info(`==> 🌎  Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`)
  }
});

由於 Node 端要到新版對於 ES6 支援較好,所以先用 babel-registersrc/server/index.js 去即時轉譯 server.js,但目前不建議在 production 環境使用。

// use babel-register to precompile ES6 syntax
require('babel-register');
require('./server');

現在我們來設計一下我們資料庫的 Schema,在這邊我們使用 MongoDB 的 ORM Mongoose,可以方便我們使用物件方式進行資料庫的操作:

// 引入 mongoose 和 Schema
import mongoose, { Schema } from 'mongoose';

// 使用 mongoose.model 建立新的資料表,並將 Schema 傳入
// 這邊我們設計了食譜分享的一些基本要素,包括名稱、描述、照片位置等
export default mongoose.model('Recipe', new Schema({ 
    id: String,
    name: String, 
    description: String, 
    imagePath: String,
    steps: Array,
    updatedAt: Date,
}));
// 引入 mongoose 和 Schema
import mongoose, { Schema } from 'mongoose';

// 使用 mongoose.model 建立新的資料表,並將 Schema 傳入
// 這邊我們設計了使用者的一些基本要素,包括名稱、描述、照片位置等
export default mongoose.model('User', new Schema({ 
    id: Number,
    username: String, 
    email: String,
    password: String, 
    admin: Boolean 
}));

為了方便維護,我們把 API 的部份統一在 src/server/controllers/api.js 進行管理,這部份會涉及比較多 Node 和 mongoose 的操作,若讀者尚不熟悉可以參考 mongoose 官網

import Express from 'express';
// 引入 jsonwebtoken 套件 
import jwt from 'jsonwebtoken';
// 引入 User、Recipe Model 方便進行資料庫操作
import User from '../models/user';
import Recipe from '../models/recipe';
import config from '../config';

// API Route
const app = new Express();
const apiRoutes = Express.Router();
// 設定 JSON Web Token 的 secret variable
app.set('superSecret', config.secret); // secret variable
// 使用者登入 API ,依據使用 email 和 密碼去驗證,若成功則回傳一個認證 token(時效24小時)我們把它存在 cookie 中,方便前後端存取。這邊我們先不考慮太多資訊安全的議題
apiRoutes.post('/login', function(req, res) {
  // find the user
  User.findOne({
    email: req.body.email
  }, (err, user) => {
    if (err) throw err;
    if (!user) {
      res.json({ success: false, message: 'Authentication failed. User not found.' });
    } else if (user) {
      // check if password matches
      if (user.password != req.body.password) {
        res.json({ success: false, message: 'Authentication failed. Wrong password.' });
      } else {
        // if user is found and password is right
        // create a token
        const token = jwt.sign({ email: user.email }, app.get('superSecret'), {
          expiresIn: 60 * 60 * 24 // expires in 24 hours
        });
        // return the information including token as JSON
        // 若登入成功回傳一個 json 訊息
        res.json({
          success: true,
          message: 'Enjoy your token!',
          token: token,
          userId: user._id
        });
      }   
    }
  });
});
// 初始化 api,一開始資料庫尚未建立任何使用者,我們需要在瀏覽器輸入 `http://localhost:3000/api/setup`,進行資料庫初始化。這個動作將新增一個使用者、一份食譜,若是成功新增將回傳一個 success 訊息
apiRoutes.get('/setup', (req, res) => {
  // create a sample user
  const sampleUser = new User({ 
    username: 'mark', 
    email: 'mark@demo.com', 
    password: '123456',
    admin: true 
  });
  const sampleRecipe = new Recipe({
    id: '110ec58a-a0f2-4ac4-8393-c866d813b8d1',
    name: '番茄炒蛋', 
    description: '番茄炒蛋,一道非常經典的家常菜料理。雖然看似普通,但每個家庭都有屬於自己家裡的不同味道', 
    imagePath: 'https://c1.staticflickr.com/6/5011/5510599760_6668df5a8a_z.jpg',
    steps: ['放入番茄', '打個蛋', '放入少許鹽巴', '用心快炒'],
    updatedAt: new Date()
  });
  // save the sample user
  sampleUser.save((err) => {
    if (err) throw err;
    sampleRecipe.save((err) => {
      if (err) throw err;
      console.log('User saved successfully');
      res.json({ success: true });      
    })
  });
});
// 回傳所有 recipes
apiRoutes.get('/recipes', (req, res) => {
  Recipe.find({}, (err, recipes) => {
    res.status(200).json(recipes);
  })
});

// route middleware to verify a token
// 接下來的 api 將進行控管,也就是說必須在網址請求中夾帶認證 token 才能完成請求
apiRoutes.use((req, res, next) => {
  // check header or url parameters or post parameters for token
  // 確認標頭、網址或 post 參數是否含有 token,本範例因為簡便使用網址 query 參數 
  var token = req.body.token || req.query.token || req.headers['x-access-token'];
  // decode token
  if (token) {
    // verifies secret and checks exp
    jwt.verify(token, app.get('superSecret'), (err, decoded) => {      
      if (err) {
        return res.json({ success: false, message: 'Failed to authenticate token.' });    
      } else {
        // if everything is good, save to request for use in other routes
        req.decoded = decoded;    
        next();
      }
    });
  } else {
    // if there is no token
    // return an error
    return res.status(403).send({ 
        success: false, 
        message: 'No token provided.' 
    });
  }
});
// 確認認證是否成功
apiRoutes.get('/authenticate', (req, res) => {
  res.json({
    success: true,
    message: 'Enjoy your token!',
  });
});
// create recipe 新增食譜
apiRoutes.post('/recipes', (req, res) => {
  const newRecipe = new Recipe({
    name: req.body.name, 
    description: req.body.description, 
    imagePath: req.body.imagePath,
    steps: ['放入番茄', '打個蛋', '放入少許鹽巴', '用心快炒'],
    updatedAt: new Date()
  });
  newRecipe.save((err) => {
    if (err) throw err;
    console.log('User saved successfully');
    res.json({ success: true });      
  });
}); 
// update recipe 根據 _id(mongodb 的 id)更新食譜
apiRoutes.put('/recipes/:id', (req, res) => {
  Recipe.update({ _id: req.params.id }, {
    name: req.body.name, 
    description: req.body.description, 
    imagePath: req.body.imagePath,
    steps: ['放入番茄', '打個蛋', '放入少許鹽巴', '用心快炒'],
    updatedAt: new Date()
  } ,(err) => {
    if (err) throw err;
    console.log('User updated successfully');
    res.json({ success: true });      
  });
});
// remove recipe 根據 _id 刪除食譜,若成功回傳成功訊息
apiRoutes.delete('/recipes/:id', (req, res) => {
  Recipe.remove({ _id: req.params.id }, (err, recipe) => {
    if (err) throw err;
    console.log('remove saved successfully');
    res.json({ success: true }); 
  });
}); 
export default apiRoutes;

設定整個 App 的 routing,我們主要頁面有 HomePageContainerLoginPageContainerSharePageContainer,值得注意的是我們這邊使用 Higher Order Components (Higher Order Components 為一個函數, 接收一個 Component 後在 Class Component 的 render 中 return 回傳入的 components)方式去確認使用者是否有登入,若有沒登入則不能進入分享食譜頁面,反之若已登入也不會再進到登入頁面:

import React from 'react';
import { Route, IndexRoute } from 'react-router';
import Main from '../components/Main';
import CheckAuth from '../components/CheckAuth';
import HomePageContainer from '../containers/HomePageContainer';
import LoginPageContainer from '../containers/LoginPageContainer';
import SharePageContainer from '../containers/SharePageContainer';

export default (
  <Route path='/' component={Main}>
    <IndexRoute component={HomePageContainer} />
    <Route path="/login" component={CheckAuth(LoginPageContainer, 'guest')}/>
    <Route path="/share" component={CheckAuth(SharePageContainer, 'auth')}/>
  </Route>
);

設定行為常數(src/constants/actionTypes.js):

export const AUTH_START    = "AUTH_START";
export const AUTH_COMPLETE = "AUTH_COMPLETE";
export const AUTH_ERROR    = "AUTH_ERROR";
export const START_LOGOUT    = "START_LOGOUT";
export const CHECK_AUTH    = "CHECK_AUTH";
export const SET_USER    = "SET_USER";
export const SHOW_SPINNER    = "SHOW_SPINNER";
export const HIDE_SPINNER    = "HIDE_SPINNER";
export const SET_UI    = "SET_UI";
export const GET_RECIPES = 'GET_RECIPES';
export const SET_RECIPE = 'SET_RECIPE';
export const ADD_RECIPE = 'ADD_RECIPE';
export const UPDATE_RECIPE = 'UPDATE_RECIPE';
export const DELETE_RECIPE = 'DELETE_RECIPE';

設定 src/actions/recipeActions.js,我們這邊使用 redux-promise,可以很容易使用非同步的行為 WebAPI:

import { createAction } from 'redux-actions';
import WebAPI from '../utils/WebAPI';

import {
  GET_RECIPES,
  ADD_RECIPE,
  UPDATE_RECIPE,
  DELETE_RECIPE,
  SET_RECIPE,
} from '../constants/actionTypes';

export const getRecipes = createAction('GET_RECIPES', WebAPI.getRecipes);
export const addRecipe = createAction('ADD_RECIPE', WebAPI.addRecipe);
export const updateRecipe = createAction('UPDATE_RECIPE', WebAPI.updateRecipe);
export const deleteRecipe = createAction('DELETE_RECIPE', WebAPI.deleteRecipe);
export const setRecipe = createAction('SET_RECIPE');

設定 src/actions/uiActions.js

import { createAction } from 'redux-actions';
import WebAPI from '../utils/WebAPI';

import {
  SHOW_SPINNER,
  HIDE_SPINNER,
  SET_UI,
} from '../constants/actionTypes';

export const showSpinner = createAction('SHOW_SPINNER');
export const hideSpinner = createAction('HIDE_SPINNER');
export const setUi = createAction('SET_UI');

設定 src/actions/userActions.js,處理使用者登入登出等行為:

import { createAction } from 'redux-actions';
import WebAPI from '../utils/WebAPI';

import {
  AUTH_START,
  AUTH_COMPLETE,
  AUTH_ERROR,
  START_LOGOUT,
  CHECK_AUTH,
  SET_USER
} from '../constants/actionTypes';

export const authStart = createAction('AUTH_START', WebAPI.login);
export const authComplete = createAction('AUTH_COMPLETE');
export const authError = createAction('AUTH_ERROR');
export const startLogout = createAction('START_LOGOUT', WebAPI.logout);
export const checkAuth = createAction('CHECK_AUTH');
export const setUser = createAction('SET_USER');

scr/actions/index.js 輸出 actions:

export * from './userActions';
export * from './recipeActions';
export * from './uiActions';

scr/common/utils/fetchComponentData.js 設定 server side 初始 fetchComponentData:

// 這邊使用 axios 方便進行 promises base request
import axios from 'axios';
// 記得附加上我們存在 cookies 的 token  
export default function fetchComponentData(token = 'token') {
  const promises = [axios.get('http://localhost:3000/api/recipes'), axios.get('http://localhost:3000/api/authenticate?token=' + token)];
  return Promise.all(promises);
}

scr/common/utils/WebAPI.js 所有前端 API 的處理:

import axios from 'axios';
import { browserHistory } from 'react-router';
// 引入 uuid 當做食譜 id
import uuid from 'uuid';

import { 
  authComplete,
  authError,
  hideSpinner,
  completeLogout,
} from '../actions';

// getCookie 函數傳入 key 回傳 value
function getCookie(keyName) {
  var name = keyName + '=';
  const cookies = document.cookie.split(';');
  for(let i = 0; i < cookies.length; i++) {
      let cookie = cookies[i];
      while (cookie.charAt(0)==' ') {
          cookie = cookie.substring(1);
      }
      if (cookie.indexOf(name) == 0) {
        return cookie.substring(name.length, cookie.length);
      }
  }
  return "";
}

export default {
  // 呼叫後端登入 api
  login: (dispatch, email, password) => {
    axios.post('/api/login', {
      email: email,
      password: password
    })
    .then((response) => {
      if(response.data.success === false) {
        dispatch(authError()); 
        dispatch(hideSpinner());  
        alert('發生錯誤,請再試一次!');
        window.location.reload();        
      } else {
        if (!document.cookie.token) {
          let d = new Date();
          d.setTime(d.getTime() + (24 * 60 * 60 * 1000));
          const expires = 'expires=' + d.toUTCString();
          document.cookie = 'token=' + response.data.token + '; ' + expires;
          dispatch(authComplete());
          dispatch(hideSpinner());  
          browserHistory.push('/'); 
        }
      }
    })
    .catch(function (error) {
      dispatch(authError());
    });
  },
  // 呼叫後端登出 api  
  logout: (dispatch) => {
    document.cookie = 'token=; ' + 'expires=Thu, 01 Jan 1970 00:00:01 GMT;';
    dispatch(hideSpinner());  
    browserHistory.push('/'); 
  },
  // 確認使用者是否登入    
  checkAuth: (dispatch, token) => {
    axios.post('/api/authenticate', {
      token: token,
    })
    .then((response) => {
      if(response.data.success === false) {
        dispatch(authError()); 
      } else {
        dispatch(authComplete());
      }
    })
    .catch(function (error) {
      dispatch(authError());
    });
  },
  // 取得目前所有食譜    
  getRecipes: () => {
    axios.get('/api/recipes')
    .then((response) => {
    })
    .catch((error) => {
    });
  },
  // 呼叫新增食譜 api,記得附加上我們存在 cookies 的 token  
  addRecipe: (dispatch, name, description, imagePath) => {
    const id = uuid.v4();
    axios.post('/api/recipes?token=' + getCookie('token'), {
      id: id,
      name: name,
      description: description,
      imagePath: imagePath,
    })
    .then((response) => {
      if(response.data.success === false) {
        dispatch(hideSpinner());  
        alert('發生錯誤,請再試一次!');
        browserHistory.push('/share');         
      } else {
        dispatch(hideSpinner());  
        window.location.reload();        
        browserHistory.push('/'); 
      }
    })
    .catch(function (error) {
    });
  },
  // 呼叫更新食譜 api,記得附加上我們存在 cookies 的 token  
  updateRecipe: (dispatch, recipeId, name, description, imagePath) => {
    axios.put('/api/recipes/' + recipeId + '?token=' + getCookie('token'), {
      id: recipeId,
      name: name,
      description: description,
      imagePath: imagePath,
    })
    .then((response) => {
      if(response.data.success === false) {
        dispatch(hideSpinner());  
        dispatch(setRecipe({ key: 'recipeId', value: '' }));
        dispatch(setUi({ key: 'isEdit', value: false }));
        alert('發生錯誤,請再試一次!');
        browserHistory.push('/share');         
      } else {
        dispatch(hideSpinner());  
        window.location.reload();        
        browserHistory.push('/'); 
      }
    })
    .catch(function (error) {
    });
  },
  // 呼叫刪除食譜 api,記得附加上我們存在 cookies 的 token  
  deleteRecipe: (dispatch, recipeId) => {
    axios.delete('/api/recipes/' + recipeId + '?token=' + getCookie('token'))
    .then((response) => {
      if(response.data.success === false) {
        dispatch(hideSpinner());  
        alert('發生錯誤,請再試一次!');
        browserHistory.push('/');         
      } else {
        dispatch(hideSpinner());  
        window.location.reload();        
        browserHistory.push('/'); 
      }
    })
    .catch(function (error) {
    });    
  } 
};

接下來設定我們的 reducers,以下是 src/common/reducers/data/recipeReducers.jsGET_RECIPES 負責將後端 API 取得的所有食譜存放在 recipes 中:

import { handleActions } from 'redux-actions';
import { RecipeState } from '../../constants/models';

import {
  GET_RECIPES,
  SET_RECIPE,
} from '../../constants/actionTypes';

const recipeReducers = handleActions({
  GET_RECIPES: (state, { payload }) => (
    state.set(
      'recipes',
      payload.recipes
    )
  ),
  SET_RECIPE: (state, { payload }) => (
    state.setIn(payload.keyPath, payload.value)
  ),  
}, RecipeState);

export default recipeReducers;

以下是 src/common/reducers/data/userReducers.js,負責確認登入相關處理事項。注意的是由於登入是非同步執行,所以會有幾個階段的行為要做處理:

import { handleActions } from 'redux-actions';
import { UserState } from '../../constants/models';

import {
  AUTH_START,
  AUTH_COMPLETE,
  AUTH_ERROR,
  LOGOUT_START,
  SET_USER,
} from '../../constants/actionTypes';

const userReducers = handleActions({
  AUTH_START: (state) => (
    state.merge({
      isAuthorized: false,      
    })
  ),  
  AUTH_COMPLETE: (state) => (
    state.merge({
      email: '',
      password: '',
      isAuthorized: true,
    })
  ),  
  AUTH_ERROR: (state) => (
    state.merge({
      username: '',
      email: '',
      password: '',
      isAuthorized: false,
    })
  ),  
  START_LOGOUT: (state) => (
    state.merge({
      isAuthorized: false,      
    })
  ), 
  CHECK_AUTH: (state) => (
    state.set('isAuthorized', true)
  ),
  SET_USER: (state, { payload }) => (
    state.set(payload.key, payload.value)
  ),
}, UserState);

export default userReducers;

以下是 src/common/reducers/ui/uiReducers.js,負責確認 UI State 相關處理:

import { handleActions } from 'redux-actions';
import { UiState } from '../../constants/models';

import {
  SHOW_SPINNER,
  HIDE_SPINNER,
  SET_UI,
} from '../../constants/actionTypes';

const uiReducers = handleActions({
  SHOW_SPINNER: (state) => (
    state.set(
      'spinnerVisible',
      true
    )
  ),
  HIDE_SPINNER: (state) => (
    state.set(
      'spinnerVisible',
      false
    )
  ),
  SET_UI: (state, { payload }) => (
    state.set(payload.key, payload.value)
  ),    
}, UiState);

export default uiReducers;

最後把所有 recipes 在 src/common/reducers/index.js 使用 combineReducers 整合在一起,注意的是我們整個 App 的資料流要維持 immutable:

import { combineReducers } from 'redux-immutable';
import ui from './ui/uiReducers';
import recipe from './data/recipeReducers';
import user from './data/userReducers';
// import routes from './routes';

const rootReducer = combineReducers({
  ui,
  recipe,
  user,
});

export default rootReducer;

以下是 src/common/store/configureStore.js 處理 store 的建立,這次我們使用了 promiseMiddleware 的 middleware:

import { createStore, applyMiddleware } from 'redux';
import promiseMiddleware from 'redux-promise';
import createLogger from 'redux-logger';
import Immutable from 'immutable';
import rootReducer from '../reducers';

const initialState = Immutable.Map();

export default function configureStore(preloadedState = initialState) {
  const store = createStore(
    rootReducer,
    preloadedState,
    applyMiddleware(createLogger({ stateTransformer: state => state.toJS() }, promiseMiddleware))
  );

  return store;
}

經過一連串努力,我們來到了 View 的佈建。在這個 App 中我們主要會由一個 AppBar 負責所有頁面的導覽,也就是每個頁面都會有 AppBar 常駐在上面,然而上面的內容則會依 UI State 中的 isAuthorized 而有所不同。最後要留意的是我們使用了 React Bootstrapt 來建立 React Component。

import React from 'react';
import { LinkContainer } from 'react-router-bootstrap';
import { Link } from 'react-router';
import { Navbar, Nav, NavItem, NavDropdown, MenuItem } from 'react-bootstrap';

const AppBar = ({
  isAuthorized,
  onToShare,
  onLogout,
}) => (
  <Navbar>
    <Navbar.Header>
      <Navbar.Brand>
        <Link to="/">OpenCook</Link>
      </Navbar.Brand>
      <Navbar.Toggle />
    </Navbar.Header>
    <Navbar.Collapse>
      {
        isAuthorized === false ?
        (
          <Nav pullRight>
            <LinkContainer to={{ pathname: '/login' }}><NavItem eventKey={2} href="#">登入</NavItem></LinkContainer>
          </Nav>
        ) :
        (
          <Nav pullRight>
            <NavItem eventKey={1} onClick={onToShare}>分享食譜</NavItem>
            <NavItem eventKey={2} onClick={onLogout} href="#">登出</NavItem>
          </Nav>
        )        
      }
    </Navbar.Collapse>
  </Navbar>
);

export default AppBar;

以下是 src/common/containers/AppBarContainer/AppBarContainer.js

import React from 'react';
import { connect } from 'react-redux';
import AppBar from '../../components/AppBar';
import { browserHistory } from 'react-router';

import {
  startLogout,
  setRecipe,
  setUi,
} from '../../actions';

export default connect(
  (state) => ({
    isAuthorized: state.getIn(['user', 'isAuthorized']),
  }),
  (dispatch) => ({
    onToShare: () => {
      dispatch(setRecipe({ key: 'recipeId', value: '' }));
      dispatch(setUi({ key: 'isEdit', value: false }));
      window.location.reload();        
      browserHistory.push('/share'); 
    },
    onLogout: () => (
      dispatch(startLogout(dispatch))
    ),
  })
)(AppBar);

以下是 src/components/Main/Main.js,透過 route 機制讓 AppBarContainer 可以成為整個 App 母模版:

import React from 'react';
import AppBarContainer from '../../containers/AppBarContainer';

const Main = (props) => (
  <div>
    <AppBarContainer />
    <div>
      {props.children}
    </div>
  </div>
);

export default Main;

checkAuth 這個 Component 中,我們使用到了 Higher Order Components 的觀念。Higher Order Components 為一個函數, 接收一個 Component 後在 Class Component 的 render 中 return 回傳入的 components 方式去確認使用者是否有登入,若有沒登入則不能進入分享食譜頁面,反之若已登入也不會再進到登入頁面:

import React from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';

// High Order Component
export default function requireAuthentication(Component, type) {
  class AuthenticatedComponent extends React.Component {
    componentWillMount() {
      this.checkAuth();
    }
    componentWillReceiveProps(nextProps) {
      this.checkAuth();
    }
    checkAuth() {
      if(type === 'auth') {
        if (!this.props.isAuthorized) {
          this.props.router.push('/');
        }        
      } else {
        if (this.props.isAuthorized) {
          this.props.router.push('/');
        }                
      }
    }
    render() {
      return ( 
        <div> 
        {
          (type === 'auth') ?
          this.props.isAuthorized === true ? <Component {...this.props } /> : null
          : this.props.isAuthorized === false ? <Component {...this.props } /> : null
        } 
        </div>
      )
    }
  };
  const mapStateToProps = (state) => ({
    isAuthorized: state.getIn(['user', 'isAuthorized']),
  });
  return connect(mapStateToProps)(withRouter(AuthenticatedComponent));
}

我們將每個食譜呈現設計成 RecipeBox,以下是在 src/common/components/HomePage/HomePage.js 使用 map 方法去迭代我們的食譜:

import React from 'react';
import RecipeBoxContainer from '../../containers/RecipeBoxContainer';

const HomePage = ({
  recipes
}) => (
  <div>        
  {
    recipes.map((recipe, index) => (
      <RecipeBoxContainer recipe={recipe} key={index}  />
    )).toJS()
  }
  </div>
);

export default HomePage;

以下是 src/common/containers/HomePageContainer/HomePageContainer.js

import React from 'react';
import { connect } from 'react-redux';
import HomePage from '../../components/HomePage';

export default connect(
  (state) => ({
    recipes: state.getIn(['recipe', 'recipes']),    
  }),
  (dispatch) => ({
  })
)(HomePage);

src/common/components/LoginBox/LoginBox.js 設計我們 LoginBox:

import React from 'react';
import { Form, FormGroup, Button, FormControl, ControlLabel } from 'react-bootstrap';

const LoginBox = ({
  email,
  password,
  onChangeEmailInput,
  onChangePasswordInput,
  onLoginSubmit
}) => (
  <div>
    <Form horizontal>
      <FormGroup
        controlId="formBasicText"
      >
        <ControlLabel>請輸入您的 Email</ControlLabel>
        <FormControl
          type="text"
          onChange={onChangeEmailInput}
          placeholder="Enter Email"
        />
        <FormControl.Feedback />
      </FormGroup>
      <FormGroup
        controlId="formBasicText"
      >
        <ControlLabel>請輸入您的密碼</ControlLabel>
        <FormControl
          type="password"
          onChange={onChangePasswordInput}
          placeholder="Enter Password"
        />
        <FormControl.Feedback />
      </FormGroup>
      <Button 
        onClick={onLoginSubmit} 
        bsStyle="success" 
        bsSize="large" 
        block
      >
        提交送出
      </Button>
    </Form>
  </div>
);

export default LoginBox;

以下是 src/common/containers/LoginBoxContainer/LoginBoxContainer.js

import React from 'react';
import { connect } from 'react-redux';
import LoginBox from '../../components/LoginBox';

import { 
  authStart,
  showSpinner,
  setUser,
} from '../../actions';

export default connect(
  (state) => ({
    email: state.getIn(['user', 'email']),
    password: state.getIn(['user', 'password']),
  }),
  (dispatch) => ({
    onChangeEmailInput: (event) => (
      dispatch(setUser({ key: 'email', value: event.target.value }))
    ),
    onChangePasswordInput: (event) => (
      dispatch(setUser({ key: 'password', value: event.target.value }))
    ),
    onLoginSubmit: (email, password) => () => {
      dispatch(authStart(dispatch, email, password));
      dispatch(showSpinner());
    },
  }),
  (stateProps, dispatchProps, ownProps) => {
    const { email, password } = stateProps;
    const { onLoginSubmit } = dispatchProps;
    return Object.assign({}, stateProps, dispatchProps, ownProps, {
      onLoginSubmit: onLoginSubmit(email, password),
    });
  }
)(LoginBox);

src/common/components/LoginPage/LoginPage.js,當 spinnerVisible 為 true 會顯示 spinner:

import React from 'react';
import { Grid, Row, Col, Image } from 'react-bootstrap';
import LoginBoxContainer from '../../containers/LoginBoxContainer';

const LoginPage = ({
  spinnerVisible,
}) => (
  <div>
    <Row className="show-grid">
      <Col xs={6} xsOffset={3}>
        <LoginBoxContainer />
        { spinnerVisible === true ?
          <Image src="/static/images/loading.gif" /> :
          null
        }
      </Col>
    </Row>
  </div>
);

export default LoginPage;

以下是 src/common/containers/LoginPageContainer/LoginPageContainer.js

import React from 'react';
import { connect } from 'react-redux';
import LoginPage from '../../components/LoginPage';

export default connect(
  (state) => ({
    spinnerVisible: state.getIn(['ui', 'spinnerVisible']),
  }),
  (dispatch) => ({
  })
)(LoginPage);

真正設計我們內部的食譜, src/common/components/RecipeBox,使用者登入的話可以修改和刪除食譜:

import React from 'react';
import { Grid, Row, Col, Image, Thumbnail, Button } from 'react-bootstrap';

const RecipeBox = (props) => {
  return(
      <Col xs={6} md={4}>
        <Thumbnail src={props.recipe.get('imagePath')} alt="242x200">
          <h3>{props.recipe.get('name')}</h3>
          <p>{props.recipe.get('description')}</p>
          {
            props.isAuthorized === true ? (
            <p>
              <Button bsStyle="primary" onClick={props.onDeleteRecipe(props.recipe.get('_id'))}>刪除</Button>&nbsp;
              <Button bsStyle="default" onClick={props.onUpadateRecipe(props.recipe.get('_id'))}>修改</Button>
            </p>)
            : null            
          }
        </Thumbnail>
      </Col>
    );
}

export default RecipeBox;

以下是 src/common/containers/RecipeBoxContainer/RecipeBoxContainer.js

import React from 'react';
import { connect } from 'react-redux';
import RecipeBox from '../../components/RecipeBox';
import { browserHistory } from 'react-router';

import {
  deleteRecipe,
  setRecipe,
  setUi
} from '../../actions';

export default connect(
  (state) => ({
    isAuthorized: state.getIn(['user', 'isAuthorized']),
    recipes: state.getIn(['recipe', 'recipes']),
  }),
  (dispatch) => ({
    onDeleteRecipe: (recipeId) => () => (
      dispatch(deleteRecipe(dispatch, recipeId))
    ),
    onUpadateRecipe: (recipes) => (recipeId) => () => {
      const recipeIndex = recipes.findIndex((_recipe) => (_recipe.get('_id') === recipeId));
      const recipe = recipeIndex !== -1 ? recipes.get(recipeIndex) : undefined;
      dispatch(setRecipe({ keyPath: ['recipe'], value: recipe }));
      dispatch(setRecipe({ keyPath: ['recipe', 'id'], value: recipeId }));
      dispatch(setUi({ key: 'isEdit', value: true }));
      browserHistory.push('/share?recipeId=' + recipeId); 
    },
  }),
  (stateProps, dispatchProps, ownProps) => {
    const { recipes } = stateProps; 
    const { onUpadateRecipe } = dispatchProps; 
    return Object.assign({}, stateProps, dispatchProps, ownProps, {
      onUpadateRecipe: onUpadateRecipe(recipes),
    });
  }
)(RecipeBox);

設計我們分享食譜頁面,這邊我們把編輯食譜和新增分享一起共用了同一個 components,差別在於我們會判斷 UI State 中的 isEdit, 決定相應處理方式。在中 src/common/components/ShareBox/ShareBox.js,可以讓使用者登入的後修改和刪除食譜:

import React from 'react';
import { Form, FormGroup, Button, FormControl, ControlLabel } from 'react-bootstrap';

const ShareBox = (props) => {
  return (<div>
    <Form horizontal>
      <FormGroup
        controlId="formBasicText"
      >
        <ControlLabel>請輸入食譜名稱</ControlLabel>
        <FormControl
          type="text"
          placeholder="Enter text"
          defaultValue={props.name}
          onChange={props.onChangeNameInput}
        />
        <FormControl.Feedback />
      </FormGroup>
      <FormGroup
        controlId="formBasicText"
      >
        <ControlLabel>請輸入食譜說明</ControlLabel>
        <FormControl 
          componentClass="textarea" 
          placeholder="textarea" 
          defaultValue={props.description}          
          onChange={props.onChangeDescriptionInput}
        />
        <FormControl.Feedback />
      </FormGroup>
      <FormGroup
        controlId="formBasicText"
      >
        <ControlLabel>請輸入食譜圖片網址</ControlLabel>
        <FormControl
          type="text"
          placeholder="Enter text"
          defaultValue={props.imagePath}
          onChange={props.onChangeImageUrl}
        />
        <FormControl.Feedback />
      </FormGroup>
      <Button 
        onClick={props.onRecipeSubmit} 
        bsStyle="success" 
        bsSize="large" 
        block
      >
        提交送出
      </Button>
    </Form>
  </div>);
};

export default ShareBox;

以下是 src/common/containers/ShareBoxContainer/ShareBoxContainer.js

import React from 'react';
import { connect } from 'react-redux';
import ShareBox from '../../components/ShareBox';

import { 
  addRecipe,
  updateRecipe,
  showSpinner,
  setRecipe,
} from '../../actions';

export default connect(
  (state) => ({
    recipes: state.getIn(['recipe', 'recipes']),
    recipeId: state.getIn(['recipe', 'recipe', 'id']),
    name: state.getIn(['recipe', 'recipe', 'name']),
    description: state.getIn(['recipe', 'recipe', 'description']),
    imagePath: state.getIn(['recipe', 'recipe', 'imagePath']),
    isEdit: state.getIn(['ui', 'isEdit']),
  }),
  (dispatch) => ({
    onChangeNameInput: (event) => (
      dispatch(setRecipe({ keyPath: ['recipe', 'name'], value: event.target.value }))
    ),
    onChangeDescriptionInput: (event) => (
      dispatch(setRecipe({ keyPath: ['recipe', 'description'], value: event.target.value }))
    ),
    onChangeImageUrl: (event) => (
      dispatch(setRecipe({ keyPath: ['recipe', 'imagePath'], value: event.target.value }))
    ),    
    onRecipeSubmit: (recipes, recipeId, name, description, imagePath, isEdit) => () => {
      if (isEdit === true) {
        dispatch(updateRecipe(dispatch, recipeId, name, description, imagePath));
        dispatch(showSpinner());
      } else {
        dispatch(addRecipe(dispatch, name, description, imagePath));
        dispatch(showSpinner());
      }
    },    
  }),
  (stateProps, dispatchProps, ownProps) => {
    const { recipes, recipeId, name, description, imagePath, isEdit } = stateProps;
    const { onRecipeSubmit } = dispatchProps;
    return Object.assign({}, stateProps, dispatchProps, ownProps, {
      onRecipeSubmit: onRecipeSubmit(recipes, recipeId, name, description, imagePath, isEdit),
    });
  }  
)(ShareBox);

單純的 SharePage(src/common/components/SharePage/SharePage.js)頁面:

import React from 'react';
import { Grid, Row, Col } from 'react-bootstrap';
import ShareBoxContainer from '../../containers/ShareBoxContainer';

const SharePage = () => (
  <div>
    <Row className="show-grid">
      <Col xs={6} xsOffset={3}>
        <ShareBoxContainer />
      </Col>
    </Row>
  </div>
);

export default SharePage;

以下是 src/common/containers/SharePageContainer/SharePageContainer.js

import React from 'react';
import { connect } from 'react-redux';
import SharePage from '../../components/SharePage';

export default connect(
  (state) => ({
  }),
  (dispatch) => ({
  })
)(SharePage);

恭喜你成功抵達終點!若一切順利,在終端機打上 $ npm start,你將可以在瀏覽器的 http://localhost:3000 看到自己的成果!

用 React + Redux + Node(Isomorphic)開發一個食譜分享網站

31.8 總結

本章整合過去所學和添加一些後端資料庫知識開發了一個可以登入會員並分享食譜的社群網站!快把你的成果和你的朋友分享吧!覺得意猶未盡?別忘了附錄也很精采!最後,再次謝謝讀者們支持我們一路走完了 React 開發學習之旅!然而前端技術變化很快,唯有不斷自我學習才能持續成長。筆者才疏學淺,撰寫學習心得或有疏漏,若有任何建議或提醒都歡迎和我說,大家一起加油:)

31.9 延伸閱讀

  1. joshgeller/react-redux-jwt-auth-example
  2. Securing React Redux Apps With JWT Tokens
  3. Adding Authentication to Your React Native App Using JSON Web Tokens
  4. Authentication in React Applications, Part 2: JSON Web Token (JWT)
  5. Node.js 身份認證:Passport 入門
  6. react-bootstrap compatibility #83
  7. How to authenticate routes using Passport? #725
  8. Isomorphic React Web App Demo with Material UI
  9. react-router/examples/auth-flow/
  10. redux-promise
  11. How to use redux-promise
  12. Authenticate a Node.js API with JSON Web Tokens
  13. 3 JavaScript ORMs You Might Not Know
  14. lynndylanhurley/redux-auth
  15. How to avoid getting error ‘localStorage is not defined’ on server in ReactJS isomorphic app?
  16. Where to Store your JWTs – Cookies vs HTML5 Web Storage
  17. What is the difference between server side cookie and client side cookie? [closed]
  18. Cookies vs Tokens. Getting auth right with Angular.JS
  19. Cookies vs Tokens: The Definitive Guide
  20. joshgeller/react-redux-jwt-auth-example
  21. Programmatically navigate using react router
  22. withRouter HoC (higher-order component) v2.4.0 Upgrade Guide

31.10 License

MIT, Special thanks Loading.io

31.11 🚪 任意門

回首頁 | 上一章:React Redux Sever Rendering(Isomorphic JavaScript)入門 | 下一章:附錄一、React ES5、ES6+ 常見用法對照表 |

勘誤、提問或許願 |

Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.

Using Creative Commons Public Licenses

Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.

Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License

By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License (“Public License”). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.

Section 1 – Definitions.

Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
Adapter’s License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
BY-NC-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License.
Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution, NonCommercial, and ShareAlike.
Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
Licensor means the individual(s) or entity(ies) granting rights under this Public License.
NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 – Scope.

License grant.
Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
produce, reproduce, and Share Adapted Material for NonCommercial purposes only.
Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
Term. The term of this Public License is specified in Section 6(a).
Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
Downstream recipients.
Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.
No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
Other rights.

Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
Patent and trademark rights are not licensed under this Public License.
To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes.
Section 3 – License Conditions.

Your exercise of the Licensed Rights is expressly made subject to the following conditions.

Attribution.

If You Share the Licensed Material (including in modified form), You must:

retain the following if it is supplied by the Licensor with the Licensed Material:
identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
a copyright notice;
a notice that refers to this Public License;
a notice that refers to the disclaimer of warranties;
a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
ShareAlike.
In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.

The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-NC-SA Compatible License.
You must include the text of, or the URI or hyperlink to, the Adapter’s License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter’s License You apply.
Section 4 – Sui Generis Database Rights.

Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:

for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only;
if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
Section 5 – Disclaimer of Warranties and Limitation of Liability.

Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
Section 6 – Term and Termination.

This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:

automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 – Other Terms and Conditions.

The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
Section 8 – Interpretation.

For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” The text of the Creative Commons public licenses is dedicated to the public domain under the CC0 Public Domain Dedication. Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.

Creative Commons may be contacted at creativecommons.org.

32 從零開始學 ReactJS(ReactJS 101)

一本給初學者的 React 中文入門教學書,由淺入深學習 ReactJS 生態系 (Flux, Redux, React Router, ImmutableJS, React Native, Relay/GraphQL etc.),打造跨平台應用程式。

從零開始學 ReactJS(ReactJS 101)

  1. 從零開始學 ReactJS(ReactJS 101)粉絲頁

  2. 繁體中文範例程式碼和書籍內容連載位置

  3. 勘誤、許願、建議或提問

32.2 翻譯版本(Translate)

  1. 简体中文版本 by @carlleton
  2. 前端圈简体中文版本 by @blueflylin 特別感謝前端圈小夥伴!

若需翻譯成其他語言版本,請先 fork 一份 repo 到自己的 Guthub 並另外開新的 branch。最後將翻譯版本連結更新在 master 分支中 README.md相關連結(Links) 後發送 Pull Request,謝謝您。

32.3 目錄(Table of Contents)

32.4 先備知識(Prior Knowledge)

本書針對已具備基本 HTML、CSS 和 JavaScript 和 DOM 操作知識的讀者設計,但若讀者對上述的技術仍不熟悉的話,建議可以先行參考:MDNCodecademyW3C SchoolJavaScript核心 或是參考筆者 之前的教學講義 進行學習。另外,本書全書範例都將以 ES6+ 撰寫,若需參考 ES5 用法,請參考附錄一的 React ES5、ES6+ 常見用法對照表

32.5 關於作者(Author)

@kdchang 文藝型開發者,夢想是做出人們想用的產品和辦一所心目中理想的學校,目前專注在 Mobile 和 IoT 應用開發。A Starter & Maker. JavaScript, Python & Arduino/Android lover.:)

32.6 版權許可(License)

本書採用創用CC授權4.0 “姓名標示─非商業性─相同方式分享(BY-NC-SA)” 授權。

從零開始學 ReactJS(ReactJS 101)

本授權條款允許使用者重製、散布、傳輸以及修改著作,但不得為商業目的之使用。若使用者修改該著作時,僅得依本授權條款或與本授權條款類似者來散布該衍生作品。使用時必須按照著作人指定的方式表彰其姓名。

詳細資訊請參考 CC BY-NC-SA 4.0

32.7 關鍵字(Keywords)

React, React Native, React Router, Flux, Redux, Node, Express, ImmutableJS, NPM, Babel, Browserify, Webpack, Gulp, Grunt, Pure Functions, PropTypes, Stateless Functional Components, Presentational Components, ES6, ES5, JSX, Jest, Unit Test, Component, Relay, GraphQL, Universal/Isomorphic, React Tutorial React教程, React教學, 學React, React Tutorial, Tutorial, Ecosystem, Front-End

33 Summary

© https://gittobook.org, 2018. Unauthorized use and/or duplication of this material without express and written permission from this author and/or owner is strictly prohibited. Excerpts and links may be used, provided that full and clear credit is given to this site, with appropriate and specific direction to the original content.
Table