本篇是 React 這套 JavaScript 函式庫的入門教學,介紹如何使用 React 開發互動式的網頁應用程式。

React 是 facebook 官方所維護的開放原始碼 JavaScript 函式庫,可以降低互動式網頁應用程式開發難度,自動處理各種複雜 UI 組件與資料間的連動關係,改善應用程式執行效能。

學習用 React 範本

對於初學者說,建議可以使用以下這份 React 的學習專用範本,直接將這段 HTML 原始碼貼在自己的編輯器中,儲存成 HTML 網頁檔,以瀏覽器打開後即可看到 React 的 hello world 程式執行的結果。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello World</title>

    <!-- 引入 React -->
    <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

    <!-- 引入 Babel -->
    <script src="https://unpkg.com/babel-standalone@6.15.0/babel.min.js"></script>

  </head>
  <body>
    <div id="root"></div>
    <script type="text/babel">

      // React 的 JSX 程式碼
      ReactDOM.render(
        <h1>Hello, world!</h1>,
        document.getElementById('root')
      );

    </script>
  </body>
</html>

在接下來的教學內容都可以使用這個 HTML 網頁檔來執行。

React 標準開發環境

使用單一 HTML 範本檔來學習 React 是很方便的作法,但這種方式的程式執行效率較低,不適合用於正式的專案。

若要開發標準的 React 專案,建議使用 create-react-app 這個工具來建立新專案,第一次使用之前請先安裝:

# 安裝 create-react-app
npm install -g create-react-app

接著建立新 React 專案:

# 建立新 React 專案
create-react-app my-app

若使用 npm 5.2 以上的版本,可以改用 npx 直接呼叫 create-react-app 來建立新專案:

# 改用 npx(免安裝 create-react-app)
npx create-react-app my-app

建立好新的 React 專案之後,進入專案目錄,啟動開發用伺服器,即可開始撰寫 React 的程式:

# 進入專案目錄
cd my-app

# 啟動開發用伺服器
npm start

若要產生可以佈署出去的專案成品,可以執行:

# 建置專案
npm run build

其餘更詳細的專案操作,請直接參考 React 的官方文件

JSX 簡介

JSX 是一種 JavaScript 的擴充語言,加入了一些 HTML 標籤的語法,下面這一行就是 JSX 典型的程式碼:

const element = <h1>Hello, world!</h1>;

在多數的應用程式中,UI 都會與資料、事件或其他 UI 有關係,因此 React 架構在設計上就將 HTML 標籤與 JavaScript 控制邏輯合併,以 JSX 來描述 UI 的外觀與運作邏輯,打造出 React 的 UI 組件(components),再用這些 UI 組件堆疊出個應用程式。

開發 React 應用程式時,不一定要使用 JSX 語言,不過通常使用 JSX 會比較方便。

JSX 內嵌 JavaScript

我們可以在 JSX 中以大括號內嵌任何的 JavaScript 運算,例如:

// 普通的 JavaScript
function formatName(user) {
  return user.firstName + ' ' + user.lastName;
}
const user = {
  firstName: 'Harper',
  lastName: 'Perez'
};

// JSX 內嵌 JavaScript
const element = (
  <h1>
    Hello, {formatName(user)}!
  </h1>
);

// 普通的 JavaScript
ReactDOM.render(
  element,
  document.getElementById('root')
);

在這裡我們將 JSX 內的標籤分成多行來寫(也可以合併為一行),這種狀況建議在頭尾加上小括號,避免不小心造成解析錯誤。

JSX 與 JavaScript

事實上 JSX 的程式碼在經過編譯之後,會轉為普通的 JavaScript 函數來執行,產生特別的 JavaScript 物件,所以 JSX 可以放在判斷式、迴圈中,也可以指定給 JavaScript 的變數、作為函數的參數或傳回值等:

// JSX 與 JavaScript 混合使用
function getGreeting(user) {
  if (user) {
    return <h1>Hello, {formatName(user)}!</h1>;
  }
  return <h1>Hello, Stranger.</h1>;
}

JSX 指定屬性值

JSX 中的網頁標籤若要指定屬性值,語法跟普通的 HTML 語法類似:

// JSX 指定屬性值
const element = <div tabIndex="0"></div>;

若要以內嵌的 JavaScript 來指定屬性值,則使用大括號將 JavaScript 的運算式包起來放進去:

// JSX 內嵌 JavaScript 指定屬性值
const element = <img src={user.avatarUrl}></img>;

這裡要注意一點:使用大括號時不可以加上引號。

JSX 標籤與子節點

JSX 跟 XML 的規則類似,若遇到非成對的標籤,最後要以 /> 結尾。

// 單一標籤
const element = <img src={user.avatarUrl} />;

JSX 可以像普通網頁一樣,包含多個子節點:

// 標籤可含有子節點
const element = (
  <div>
    <h1>Hello!</h1>
    <h2>Good to see you here.</h2>
  </div>
);

預防駭客攻擊

JSX 在內嵌資料時,會自動跳脫(escape)任何特別的字元,所以不用擔心隱碼攻擊(injection attacks):

// 使用者輸入資料
const title = response.potentiallyMaliciousInput;

// 安全無虞
const element = <h1>{title}</h1>;

JSX 代表物件

JSX 經過 Babel 編譯之後,會轉換為 React.createElement() 這個 JavaScript 的函數,也就是說以下兩段程式碼的效果是相同的:

// 原始 JSX
const element = (
  <h1 className="greeting">
    Hello, world!
  </h1>
);

// JSX 編譯後的 JavaScript
const element = React.createElement(
  'h1',
  {className: 'greeting'},
  'Hello, world!'
);

React.createElement() 會進行一些檢查,協助開發者減少錯誤發生的機會,基本上它產生的物件會類似這樣:

// 簡化的 JSX 物件結構
const element = {
  type: 'h1',
  props: {
    className: 'greeting',
    children: 'Hello, world'
  }
};

這就是典型的 React 元素,React 就是靠著這些資訊來建立與更新 DOM 的。

顯示元素

元素(elements)是 React 應用程式中最小的單元,元素中會包含其外觀的描述:

const element = <h1>Hello, world</h1>;

React 的元素屬於靜態的物件,所以在建立時非常有效率,而 React 自己會自動維護與瀏覽器 DOM 之間的同步問題。

React 的元素(elements)與組件(components )有些不同,組件是由元素所構成的,後續會有更詳細的介紹。

在 DOM 中顯示 React 元素

通常在 React 的應用程式中,我們會在網頁上放置一個類似這樣的 HTML 標籤,然後讓 React 的所有元件都顯示在這個標籤之中:

<div id="root"></div>

這樣的 HTML 節點就稱為根節點(root),通常一個應用程式中只有一個根節點,但是如果我們是將 React 整合進既有的程式當中,我們也可以加上任意數量的根節點。

若要顯示 React 的元素,可以呼叫 ReactDOM.render() 函數,並指定要顯示的 React 元素,以及放置元素的根節點:

const element = <h1>Hello, world</h1>;
ReactDOM.render(element, document.getElementById('root'));

上面這段程式碼執行之後,就會在網頁上顯示「Hello, world」的字樣。

更新已顯示的 React 元素

React 的元素是不可變更的(immutable),也就是說當我們建立好一個 React 的元素之後,就不可以更改其屬性或是子節點,它就像是電影中的一幅畫面,代表某個時間點的 UI 狀態。

以目前我們所學到的技巧來說,如果想要更新 UI,只能重新建立一個 React 元素,然後交給 ReactDOM.render() 再畫一次。

以下是一個時鐘範例:

// 更新 UI
function tick() {
  const element = (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {new Date().toLocaleTimeString()}.</h2>
    </div>
  );
  ReactDOM.render(element, document.getElementById('root'));
}

// 每秒更新一次
setInterval(tick, 1000);

這個範例中,我們每秒呼叫一次 tick() 更新 UI,這樣就可以達到動態時鐘的效果。

在實務上,大部分的 React 應用程式只會呼叫 ReactDOM.render() 函數一次,配合 React 的狀態功能來更新 UI(後續會介紹)。

React 動態時鐘

組件與屬性

在概念上來說,React 的組件(components)就像是 JavaScript 的函數,它可以接受任意的輸入(稱為 props,意指屬性),並傳回要顯示於網頁中的 React 元素。

組件最簡單的定義方式就是使用 JavaScript 的函數:

// 以 JavaScript 函數定義組件
function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

這一個 JavaScript 函數是一個有效的 React 組件,它接受單一個 props 參數,並傳回一個 React 元素。

除了 JavaScript 函數之外,亦可使用 ES6 的 class 定義 React 的組件:

// 以 ES6 class 定義組件
class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

以上兩種組件定義方式,對於 React 來說是完全一樣的。

顯示組件

前面我們只看過標準的 DOM 標籤元素:

// DOM 標籤元素
const element = <div />;

事實上在 React 中,我們也可以自行定義新的組件:

// 自訂組件
const element = <Welcome name="Sara" />;

當 React 遇到使用者自行定義新的組件時,它會將 JSX 的所有屬性打包成一個物件,透過 props 傳入該組件。

以下這個範例會在網頁上產生「Hello, Sara」的字樣:

// 自訂組件
function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

// 使用自訂組件,並傳入 props 屬性資料
const element = <Welcome name="Sara" />;
ReactDOM.render(
  element,
  document.getElementById('root')
);

這裡當 ReactDOM.render() 遇到 Welcome 組件時,就會呼叫 Welcome 函數,並將 JSX 的屬性打包為 {name: 'Sara'},作為 props 的值傳入,然後在 Welcome 函數中就可以從 props 中獲取該屬性值,產生正確的 React 元素。

React 會將所有英文小寫開頭的組件視為 DOM 標籤,例如 <div />;而若遇到大寫開頭的組件,則視為自訂組件(必須自己定義好),例如 <Welcome />

組合組件

一個組件的輸出中可以包含其他的組件,這種特性讓我們可以將網頁應用程式上各層級的元件統一都以組件的方式來表達。

以下的範例中,我們建立一個組件,其中包含三個 Welcome 組件:

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

// 自訂組件,包含其他組件
function App() {
  return (
    <div>
      <Welcome name="Sara" />
      <Welcome name="Cahal" />
      <Welcome name="Edite" />
    </div>
  );
}

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

通常新的 React 應用程式都會從一個最頂端的 <App> 組件開始打造起,但若是將 React 整合進既有的應用程式中,可能就會從最基本的零組件開始著手(例如 <Button>)。

萃取組件

在使用 React 開發網頁應用程式時,應該進可能將重複的部份獨立出來,寫成組件,可讓程式碼更簡潔。以下是一個簡單的範例:

function Comment(props) {
  return (
    <div className="Comment">
      <div className="UserInfo">
        <img className="Avatar"
          src={props.author.avatarUrl}
          alt={props.author.name}
        />
        <div className="UserInfo-name">
          {props.author.name}
        </div>
      </div>
      <div className="Comment-text">
        {props.text}
      </div>
      <div className="Comment-date">
        {formatDate(props.date)}
      </div>
    </div>
  );
}

這段程式碼非常冗長,結構也複雜,不容易維護,也不容易重複使用。

首先將這裡的 Avatar 獨立出來,寫成組件:

function Avatar(props) {
  return (
    <img className="Avatar"
      src={props.user.avatarUrl}
      alt={props.user.name}
    />
  );
}

由於 Avatar 並不一定都顯示在 Comment 當中,所以其人名的屬性以 name 命名,不用 author

這樣一來,Comment 就變得簡潔一些了:

function Comment(props) {
  return (
    <div className="Comment">
      <div className="UserInfo">
        <Avatar user={props.author} />
        <div className="UserInfo-name">
          {props.author.name}
        </div>
      </div>
      <div className="Comment-text">
        {props.text}
      </div>
      <div className="Comment-date">
        {formatDate(props.date)}
      </div>
    </div>
  );
}

接著再將 UserInfo 也獨立出來:

function UserInfo(props) {
  return (
    <div className="UserInfo">
      <Avatar user={props.user} />
      <div className="UserInfo-name">
        {props.user.name}
      </div>
    </div>
  );
}

最後完成的 Comment 為:

function Comment(props) {
  return (
    <div className="Comment">
      <UserInfo user={props.author} />
      <div className="Comment-text">
        {props.text}
      </div>
      <div className="Comment-date">
        {formatDate(props.date)}
      </div>
    </div>
  );
}

萃取元件的大原則有兩個:

  • 重複出現。
  • 結構複雜。

若程式碼符合上述任一種特性,就可以考慮將其獨立出來,寫成組件。

Props 是唯讀的

不管是以 JavaScript 函數或是 ES6 class 來定義組件,組件 props 中的值都是不可以更改的。例如:

// 沒問題
function sum(a, b) {
  return a + b;
}

上面這個範例中,我們使用 ab 來做計算,但是沒有更改 ab 裡面的內容,所以這樣是沒問題的。

但以下這樣寫,就會出問題:

// 不可以這樣寫!
function withdraw(account, amount) {
  account.total -= amount;
}

這個 withdraw 函數在執行時,會更改 account.total 內所儲存的數值,這在 React 中是不允許的。

如果 UI 需要隨時間、使用者的操作等改變其內部的狀態以及顯示的外貌,可以使用接下來要介紹的 state 功能。

State 與生命週期

在前面介紹過的技巧中,我們只能靠著 ReactDOM.render() 更新畫面:

function tick() {
  const element = (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {new Date().toLocaleTimeString()}.</h2>
    </div>
  );
  ReactDOM.render(
    element,
    document.getElementById('root')
  );
}

setInterval(tick, 1000);

接下來我們將修改這個範例,建立一個 Clock 組件,讓程式碼可以重複使用。

首先建立一個 Clock 組件,將相關的 UI 獨立出來:

// 建立 Clock 組件
function Clock(props) {
  return (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {props.date.toLocaleTimeString()}.</h2>
    </div>
  );
}

function tick() {
  ReactDOM.render(
    <Clock date={new Date()} />,
    document.getElementById('root')
  );
}

setInterval(tick, 1000);

獨立出 Clock 之後,我們希望它可以獨立運作,不要讓我們還要定時去更新它,也就是說它最好可以這樣使用:

// 獨立運作的 Clock 組件
ReactDOM.render(
  <Clock />,
  document.getElementById('root')
);

若要達到這樣的功能,我們須在 Clock 中加入一個 state(意指狀態),這個 state 就類似 props 一樣,可包含一些資料,但不同的地方是 state 存在於組件內部(私有的),只受組件本身的控制。

將函數轉為類別

接下來我們要使用的功能比較複雜一些,所以要以 ES6 class 的方式來定義組件。我們先把前面的 Clock 以 ES6 class 的方式改寫:

// 以 ES6 class 定義的 Clock 組件
class Clock extends React.Component {
  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.props.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

在類別中加入 State

將原本的 this.props.datethis.state.date 取代:

class Clock extends React.Component {
  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

加入類別的建構子(constructor),在建構子中自己設定目前時間,而 props 的值則從父類別繼承:

class Clock extends React.Component {
  // 加入建構子
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

接著就可以使用這個自動的 Clock 了:

// 使用自動的 Clock
ReactDOM.render(
  <Clock />,
  document.getElementById('root')
);

生命週期

在較大型的應用程式中,我們必須注意資源的配給與回收問題,在資源不再被使用時,就需要將其回收。

目前我們的 Clock 只會自己設置一開始的時間,後續並不會更新。接下來我們要在 Clock 顯示時,加上一個 timer,讓它可以自動持續更新時間,然後在 Clock 被移除時,自動清除 timer,這些動作可以透過類別的掛載(mount)與卸載(unmount)功能來達成。

class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  // 掛載函數
  componentDidMount() {
    this.timerID = setInterval(
      () => this.tick(),
      1000
    );
  }

  // 卸載函數
  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  tick() {
    this.setState({
      date: new Date()
    });
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

ReactDOM.render(
  <Clock />,
  document.getElementById('root')
);

這裡我們靠著掛載函數(componentDidMount)讓 Clock 在顯示後,自動啟動 timer,定時呼叫 tick() 更新 state 的時間,然後藉由卸載函數(componentWillUnmount)在 Clock 要移除前,註銷 timer。

State 注意事項

關於 state 的使用,有三個重點。

不可直接更改 State

若要更改 state 裡面的資料,不可以直接更改:

// 錯誤用法
this.state.comment = 'Hello';

要改用 setState 來設定新的值:

// 正確用法
this.setState({comment: 'Hello'});

在程式中,唯一可以直接更改 state 的地方就是在建構子之中,除此之外都必須呼叫 setState

State 的更新是非同步的

由於 this.propsthis.state 的更新可能是非同步的,所以不可以直接根據其值來計算:

// 錯誤用法
this.setState({
  counter: this.state.counter + this.props.increment,
});

若需要根據舊的 state 來計算,要改成這樣:

// 正確用法
this.setState((prevState, props) => ({
  counter: prevState.counter + props.increment
}));

這裡我們使用箭頭函數來簡化程式碼,若用傳統的語法則會像這樣:

// 正確用法
this.setState(function(prevState, props) {
  return {
    counter: prevState.counter + props.increment
  };
});

更新的 State 值會合併

state 中可以儲存多種資料,我們可以只更新其中一部份,它會自動跟原有的值合併。假設我們的 state 有兩個值:

constructor(props) {
  super(props);
  this.state = {
    posts: [],
    comments: []
  };
}

我們可以個別更新這兩個值:

componentDidMount() {
  fetchPosts().then(response => {
    this.setState({
      posts: response.posts
    });
  });

  fetchComments().then(response => {
    this.setState({
      comments: response.comments
    });
  });
}

資料向下流動原則

組件的 state 資料只有組件本身可以存取,其餘不管是父組件或是子組件都無法直接得知其內容,因此 state 有區域性以及封裝的特性。

組件可以將自己的 state 傳遞給子組件:

<h2>It is {this.state.date.toLocaleTimeString()}.</h2>

自行定義的組件亦可:

<FormattedDate date={this.state.date} />

FormattedDate 在收到 date 之後,並不會知道該資料是來自於何處。

function FormattedDate(props) {
  return <h2>It is {props.date.toLocaleTimeString()}.</h2>;
}

組件的 state 資料可以傳遞給其下方的子組件,但是不可以朝其他方向傳遞,React 中的資料都遵守這種向下流動原則。

參考資料