0%

身份驗證和身份辨識 (Authentication and identity) 幾乎是所有應用程序不可或缺的部分。知道用戶是誰,他們擁有什麼權限,是否登錄以及該用戶的唯一標識符,你的應用程序就可以正確的呈現畫面並為當前登錄的用戶返回正確的數據。

大多數應用程序都需要機制來處理用戶註冊、用戶登錄、密碼加密、和更新,以及圍繞身份管理的其他無數任務。 現代應用程序經常需要諸如,開放式身份驗證 (OAUTH)、多因素身份驗證 (MFA)、和基於時間的一次性密碼 (TOTP) 之類的東西。

過去,開發人員必須從頭開始開發所有身份驗證功能。 僅此一項任務就可能需要一組開發人員花費數週甚至數月的時間才能正確並安全地完成任務。 如今,有諸如 Auth0、Okta、和 Amazon Cognito 之類的完全託管的身份驗證服務可以為我們處理所有這一切。

在此示例中,你將學習如何透過 AWS Amplify 使用 Amazon Cognito 在 React 應用程序中正確安全地實現身份驗證。

這裡要構建一個基本的應用程序,需要進行身份驗證才能查看一個具有有關已登錄用戶的 profile 文件信息頁面。 如果用戶已登錄,則他們可以在 public 公共路由、帶有身份驗證表單的 profile 文件路由和僅對已登錄用戶開放的 protected 受保護路由之間導航。

如果他們尚未登錄,則只能查看公共路由和 profile 文件路由上的身份驗證表單。 如果用戶在未登錄時嘗試訪問受保護的路由,我們會將其重導向到身份驗證表單,以便他們可以登錄,然後在身份驗證後訪問該受保護的路由。

此應用程序還將保持用戶狀態,因此,如果他們刷新該應用程序、或離開該應用程序導航並在稍後返回,他們將會保持在登錄狀態。

Amazon Cognito

Amazon Cognito 是 AWS 完整的身份託管服務。Cognito 允許簡單安全的用戶註冊、登錄、訪問控制 (access control)、和用戶身份管理。Cognito 可以擴展到數百萬用戶,並且還支持使用社交身份提供商 (例如 Facebook、Google 和 Amazon)登錄。 對於任何應用程序的前 50,000 個用戶,它也是免費的。

Amazon Cognito 的運作方式

Cognito 有兩部分: user poolsidentity pools

  • User pools
    這提供了一個安全的用戶目錄,該目錄存儲了你的所有用戶,並可以擴展到數億用戶。 這是一項完全託管的服務。 作為無伺服器技術,很簡單的就可以設置 user pools,而不必擔心任何基礎設施。 User pools 管理所有註冊和登錄帳戶的用戶的資源。

  • Identity pools
    這允許您授權登錄到你的應用程序的用戶訪問各種其他 AWS 服務。 假設您想允許用戶訪問 Lambda 函式,以便他們可以從另一個 API 取得數據,您可以在創建 identity pools 時指定。 這些 identities 的資源可以是來自 Cognito user pools,甚至是 Facebook 或 Google。

Cognito user pools 允許你的應用程序針對服務調用各種方法來管理用戶身份,包括以下各項:

  • 註冊用戶
  • 登錄用戶
  • 登出用戶
  • 修改用戶密碼
  • 重置用戶密碼
  • 確認 MFA 代碼

Amazon Cognito 與 AWS Amplify

AWS Amplify 以各種方式支援 Amazon Cognito。 首先,你可以直接從 Amplify CLI 創建和配置 Amazon Cognito 服務。 透過 CLI 創建身份驗證服務後,你就可以使用 Amplify JavaScript client 客戶端程式庫從 JavaScript 應用程序調用各種方法。例如 signUp、signIn、和 signOut。

Amplify 還具有預配置的 UI 組件,使你可以僅使用幾行代碼來構建整個身份驗證流程,同時支援 React、React Native、Vue、和 Angular 等前端框架。

在此示例中,你將結合使用 Amplify CLI、Amplify JavaScript client 和 Amplify React UI 組件來構建一個示範路由,身份驗證和受保護路由(routing, authentication, and protected routes)的應用程序。 還將使用 React Router 路由器建立路由和 Ant Design 來為應用程序提供一些基本樣式。

創建 React 應用程序與 Amplify 專案

創建一個新的 React 應用程序:

yarn create react-app basic-authentication

cd basic-authentication

yarn add aws-amplify @aws-amplify/ui-react antd react-router-dom

現在,在新應用程序的根目錄下,創建 Amplify 專案:

amplify init
# 請按照步驟為專案命名,環境名稱並設置預設的文本編輯器。
# 接受其他所有設置的預設值,然後選擇你的 AWS Profile。

現在,Amplify 專案已初始化,我們可以創建身份驗證服務。

amplify add auth
Do you want to use the default authentication and security configuration? Default configuration
How do you want users to be able to sign in? Username
Do you want to configure advanced settings? No, I am done.

現在,身份驗證服務已配置完畢,你可以使用 amplify push 命令部署它:

amplify push
? Are you sure you want to continue? Yes

現在已經部署了身份驗證服務,我們可以開始對其進行應用。

客戶端身份驗證概述

使用 Amplify,有兩種主要方法可以在客戶端上實現身份驗證:

  • Auth class
    Amplify client 客戶端程式庫 Auth 類提供 30 多種不同的方法,使你可以處理與用戶管理相關的所有事情。 例如 Auth.signUp、Auth.signIn、和 Auth.signOut。

    你也可以使用此 Auth 類根據你的應用程序需求,創建完全自定義的身份驗證流程(custom authentication flow)。為此,你則必須自己管理所有的樣式和應用程序狀態。

  • 特定於框架的身份驗證 UI 組件
    Amplify 針對 React、React Native、Vue、和 Angular 等框架提供特定於框架更高層次的身份驗證技術。 使用這些組件,你僅需用幾行代碼,即可呈現完整的(可自定義的)身份驗證流程。

在此示例中,將使用 AWS Amplify React 程式庫中名為 withAuthenticator 的高階組件 (higher-order component HOC)以及路由,來創建受保護的路由和僅在用戶登錄後才能查看的 profile 配置文件。

構建前端應用程序

在你的應用程序專案根目錄的 src 目錄中創建以下文件:

Container.js
Nav.js
Profile.js
Protected.js
Public.js
Router.js

這些文件將執行以下的操作:

  • Container.js
    該文件包含一個組件,使用該組件可將可重複使用的樣式應用於其他組件。
  • Nav.js
    在此組件中,你將創建一個導航 UI。
  • Profile.js
    該組件將提供有關已登錄用戶的 profile 配置文件信息。 這也是我們添加身份驗證組件以進行註冊和登錄的組件。
  • Protected.js
    這是我們用來示範受保護路由的組件。 如果用戶已登錄,他們將能夠查看此路由。 如果未登錄,他們將重新導向到登錄表單。
  • Public.js
    這是一個基本路由,無論用戶是否登錄都可以查看。
  • Router.js
    該文件將保存路由器狀態和確定當前路由名稱。
Container 組件

首先,讓我們創建將用於此應用程序的最簡單的組件,即 Container 組件。 我們將使用此組件來包裝所有其他組件,以便在組件之間應用一些可重用的樣式:

src/Container.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react'

const Container = ({ children }) => (
<div style={styles.container}>
{ children }
</div>
)

const styles = {
container: {
margin: '0 auto',
padding: '50px 100px'
}
}

export default Container

你現在可以在整個應用程序中使用此組件以應用一致的樣式,而不必在每個組件中重寫樣式。 你可以像這樣使用它:

1
2
3
<Container>
<h1>Hello Tainan</h1>
</Container>

Container 組件的子組件的樣式都將使用 Container 組件中設置的樣式進行呈現。 這樣做可以讓你擁有一個可以控制樣式的地方。 如果你以後想要更改樣式,則只需調整一個組件。

Public 組件

這個組件僅簡單的呈現路由名稱到 UI 介面,無論用戶是否登錄均可訪問。在此組件中,將使用 Container 組件添加一些 padding 和 margin 樣式。

src/Public.js
1
2
3
4
5
6
7
8
9
10
import React from 'react'
import Container from './Container'

const Public = () => (
<Container>
<h1>Public route</h1>
</Container>
)

export default Public

Nav(導航)組件將利用 Ant Design 程式庫和 React Router。 Ant Design 將提供 Menu 和 Icon 組件,以使菜單看起來更加美觀;而 React Router 將提供 Link 組件,以便我們可以鏈接和導航至應用程序的不同部分。

你還會注意到,有一個 current 的屬性傳遞到組件中,該屬性代表當前路由的名稱。 對於此應用程序,該值將為 home、profile、或 protected。 current 的值用於 Menu 組件的 selectedKeys 陣列中,以在導航欄中突顯當前的路由。
這個 current 值將在 Router 組件中計算,並作為屬性傳遞給這個 Nav 組件:

src/Nav.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React from 'react'
import { Link } from 'react-router-dom'
import { Menu } from 'antd'
import {
HomeOutlined, ProfileOutlined, FileProtectOutlined
} from '@ant-design/icons'

const Nav = ({ current = 'home' }) => (
<div>
<Menu selectedKeys={[current]} mode="horizontal">
<Menu.Item key="home">
<Link to={'/'}>
<HomeOutlined />Home
</Link>
</Menu.Item>
<Menu.Item key="profile">
<Link to={'/profile'}>
<ProfileOutlined />Profile
</Link>
</Menu.Item>
<Menu.Item key="protected">
<Link to={'/protected'}>
<FileProtectOutlined />Protected
</Link>
</Menu.Item>
</Menu>
</div>
)

export default Nav;
Protected 組件

Protected 組件將是受保護或專用的路由。 如果嘗試訪問此路由的用戶已登錄,則他們將能夠查看此路由。 如果未登錄,則將其重導向到 profile 頁面進行註冊或登錄。

在此組件中,將使用 React 的 useEffect 掛鉤和 AWS amplify 的 Auth 類:

  • useEffect
    這是一個 React hook,允許你在函式組件中執行有副作用(side effects)的運算。 該掛鉤接受一個函式,該函式在首次渲染時或(可選擇性的)在其每次渲染時都會被調用。 透過傳入一個空陣列作為第二個參數,則表示只會在組件初次載入時只觸發一次函式。

  • Auth
    AWS Amplify class 處理用戶身份管理。 你可以使用此類完成註冊用戶、登錄、到重新設置密碼的所有操作。 在此組件中,我們將調用 Auth.currentAuthenticatedUser 方法,該方法將檢查用戶當前是否已登錄,如果是,則返回有關已登錄用戶的數據。

src/Protected.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { useEffect } from 'react'
import { Auth } from 'aws-amplify'
import Container from './Container'

const Protected = ({ history }) => {
useEffect(() => {
Auth.currentAuthenticatedUser()
.catch(() => history.push('/profile'))
}, [])
return (
<Container>
<h1>Protected route</h1>
</Container>
)
}

export default Protected

呈現組件後,我們透過在 useEffect 掛鉤中調用 Auth.currentAuthenticatedUser 來檢查用戶是否登錄到應用程序。 如果此 API 調用失敗,則意味著該用戶尚未登錄,我們需要將其重定向。 我們透過調用 history.push(‘/profile’) 重新定向。

在這個元件中我們使用 useEffect 掛鉤以保護需要身份驗證的路由。我們可以創建一個自定義的 protectedRoute 掛鉤用在需要保護的路由上,這樣,可以消除需要在各個組件上重複身份驗證的任何代碼。

在 src 目錄中,創建一個名為 protectedRoute.js 的新文件,並添加以下代碼:

src/protectedRoute.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React, { useEffect } from 'react'
import { Auth } from 'aws-amplify'

const protectedRoute = (Comp, route = '/profile') => (props) => {
async function checkAuthState() {
try {
await Auth.currentAuthenticatedUser()
} catch (err) {
props.history.push(route)
}
}

useEffect(() => {
checkAuthState()
}, []);

return <Comp {...props} />
}

export default protectedRoute

我們可以使用這個自定義的 protectedRoute 掛鉤來保護任何需要保護的組件:

1
2
3
4
5
export default protectedRoute(App)



export default protectedRoute(App, '/about-us')

現在,我們可以重構我們的應用程序,更新 Protected 組件以使用此新的 protectedRoute 掛鉤:

src/Protected.js
1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react'
import Container from './Container'
import protectedRoute from './protectedRoute'

const Protected = () => {
return (
<Container>
<h1>Protected route</h1>
</Container>
);
}

export default protectedRoute(Protected)
Router 組件

Router 組件將定義我們在應用程序中可用的組件和路由。

這個組件將用基於 window.location.href 屬性來設置當前(current)路由名稱,該名稱將用於 Nav 組件,以突顯當前的路由。

這裡還將使用 React Router 的組件 HashRouter、Switch 、和 Route:

  • HashRouter
    這是使用 URL 的 hash 部分(即 window.location.hash),使你的 UI 與 URL 保持同步的路由器。
  • Switch
    Switch 呈現與位置匹配的第一個子路由。 這與僅使用路由器的預設功能不同,Switch 可以設定渲染多個與位置匹配的路由。
  • Route
    這個組件使你可以基於 path 參數定義要渲染的組件:
src/Router.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import React, { useState, useEffect } from 'react'
import { HashRouter, Switch, Route } from 'react-router-dom'

import Nav from './Nav'
import Public from './Public'
import Profile from './Profile'
import Protected from './Protected'

const Router = () => {
const [current, setCurrent] = useState('home');

useEffect(() => {
setRoute()
window.addEventListener('hashchange', setRoute)
return () => window.removeEventListener('hashchange', setRoute)
}, [])

function setRoute() {
const location = window.location.href.split('/')
const pathname = location[location.length - 1]
setCurrent(pathname ? pathname : 'home')
}

return (
<HashRouter>
<Nav current={current} />
<Switch>
<Route exact path="/" component={Public} />
<Route exact path="/protected" component={Protected} />
<Route exact path="/profile" component={Profile} />
<Route component={Public} />
</Switch>
</HashRouter>
);
}

export default Router

在此組件的 useEffect 掛鉤內,我們透過調用 setRoute 來設置路由名稱。 我們還設置了一個事件偵聽器,以在路由更改時調用 setRoute,以反映當前的路由。

Profile 組件

我們要完成應用程序的最後一個組件是 Profile 組件。 這個組件將執行以下操作:

  • 如果用戶未登錄,則呈現身份驗證表單
  • 提供登出按鈕
  • 將用戶的個人資料信息呈現到 UI

這裡將使用 withAuthenticator 的高階組件(higher-order component HOC)包裝 Profile 組件來呈現身份驗證流程。如果用戶未登錄,這將顯示註冊/登錄表單,如果用戶已登錄,則將顯示帶有用戶個人資料詳細信息的 UI。

我們使用 AmplifySignOut UI 組件來讓用戶登出。該組件將登出用戶,然後重新呈現 UI 以顯示身份驗證表單。

要顯示用戶個人資料數據,我們使用 Auth.currentAuthenticatedUser 方法。 如果用戶已登錄,則此方法將返回用戶配置文件數據以及有關的信息。

src/Profile.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React, { useState, useEffect } from 'react'
import { Auth } from 'aws-amplify'
import { withAuthenticator, AmplifySignOut } from '@aws-amplify/ui-react'
import Container from './Container'

const Profile = () => {
const [user, setUser] = useState({})

useEffect(() => {
checkUser()
}, [])

async function checkUser() {
try {
const data = await Auth.currentUserPoolUser()
const userInfo = { username: data.username, ...data.attributes }
setUser(userInfo)
} catch (err) { console.log('error: ', err) }
}

return (
<Container>
<h1>Profile</h1>
<h2>Username: {user.username}</h2>
<h3>Email: {user.email}</h3>
<h4>Phone: {user.phone_number}</h4>
<AmplifySignOut />
</Container>
);
}

export default withAuthenticator(Profile)
樣式化 UI 組件

在背後,Amplify 使用 Web 組件實現它的 UI 組件,這意味著我們可以將它們當成 HTML 元素的第一等類別來使用 CSS 樣式。 我們希望我們的 UI 組件與應用程序其他部分中的顏色匹配,我們可以在 index.css 的底部添加以下 CSS 屬性,以定義我們要使用的顏色:

src/index.css
1
2
3
4
5
:root {
--amplify-primary-color: #1890ff;
--amplify-primary-tint: #1890ff;
--amplify-primary-shade: #1890ff;
}
配置應用程序

現在,應用程序組件已構建。 我們需要做的最後一件事是更新 index.js 以導入 Router 路由器並添加 Amplify 配置。以及為 Ant Design 程式庫導入必要的 CSS 樣式。

src/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import Router from './Router'
import 'antd/dist/antd.css'

import Amplify from 'aws-amplify'
import config from './aws-exports'
Amplify.configure(config)

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

現在啟動應用程序:

1
yarn start

現在測試一下你的應用程序,註冊一個用戶、確認 MFA 代碼 (驗證碼會透過 email 傳送)、登入、登出以及受保護的路由。

在此示例中,我們學到了幾點:

  • 使用 withAuthenticator HOC 可以快速啟動並運行預配置的身份驗證流程。

  • 使用 Auth 類可對身份驗證進行更細粒度的控制,並獲取有關當前登錄用戶的數據。

  • Ant Design 可幫助你進行預配置設計,而無需編寫任何特定於樣式的代碼。

之前的範例中,我們結合使用 API Gateway 和無伺服器函式創建了一個基本的 API 層。 這種組合非常強大,但是我們尚未與真實的資料庫進行互動。

在本範例中,我們將創建一個與 DynamoDB NoSQL 資料庫互動以執行 CRUD + L (create,read,update,delete 和 list) 操作的 GraphQL API。 你將了解什麼是 GraphQL,為什麼開發人員採用它,以及它是如何工作的。

我們將構建一個便箋應用程序 (notes app),該應用程序將允許用戶創建,更新和刪除便箋。 它還將啟用 GraphQL 訂閱,以便能即時取得更新,如果有用戶正在使用該應用程序,並且新增了便箋,則其他的用戶將會即時看到新增的便箋。

有關 GraphQL,你可以參考之前的文章,什麼是 GraphQL ?

儘管有多種方法可以實現 GraphQL 伺服器,但在本範例中,我們將使用 AWS AppSync。 AppSync 是一項託管服務,使我們能夠使用 Amplify CLI 快速輕鬆地部署 GraphQL API,解析器 (resolvers) 和數據源。

創建 GraphQL API

現在,我們要使用 GraphQL API 來構建 Notes 應用程序。

首先要創建一個新的 React 應用程序並安裝必要的依賴項。 該應用程序將使用 AWS Amplify client 程式庫與 API 互動,使用 uuid 用於創建唯一的 ID 和用於樣式設置的 Ant Design 程式庫。

yarn create react-app notesapp

cd notesapp

yarn add aws-amplify antd uuid

現在,在新應用程序的根目錄下,創建 Amplify 專案:

amplify init
? Enter a name for the project: notesapp
? Enter a name for the environment: dev
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building: javascript
Please tell us about your project
? What javascript framework are you using: react
? Source Directory Path: src
? Distribution Directory Path: build
? Build Command: npm.cmd run-script build
? Start Command: npm.cmd run-script start
? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default

初始化 Amplify 專案後,我們可以添加 GraphQL API:

amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: notesapi
? Choose the default authorization type for the API: API key
? Enter a description for the API key: public
? After how many days from now the API key should expire (1-365): 365
? Do you want to configure advanced settings for the GraphQL API: No, I am done.
? Do you have an annotated GraphQL schema? No
? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)
? Do you want to edit the schema now? (y/N) Y

接下來,在你的編輯器中打開位於 notesapp/amplify/backend/api/notesapi/schema.graphql 的基本 GraphQL schema (由 CLI 生成)。 將 schema 更新為以下內容:

1
2
3
4
5
6
7
type Note @model {
id: ID!
clientId: ID
name: String!
description: String
completed: Boolean
}

這個 schema 具有一個主要的 Note 類型,其中包含五個字段。字段可以為可為 nullable (不是必需)或 non-nullable (必需)。 用 ! 指定 non-nullable 字段。

此架構中的 Note 類型以 @model 指令註釋。 該指令不是 GraphQL SDL 的一部分; 它是 AWS Amplify GraphQL Transform 程式庫的一部分。

GraphQL Transform 程式庫允許你使用 @model、@connection、@auth 等不同的指令註釋GraphQL schema。

我們在此架構中使用的 @model 指令會將基本 Note 類型轉換為擴展的 AWS AppSync GraphQL API,並具有:

  1. 附加的 schema queries 和 mutations 的其他架構定義 (Create、Read、Update、Delete、和 List 操作)。
  2. 附加的 GraphQL schema subscriptions 的架構定義。
  3. DynamoDB 資料庫。
  4. 映射到 DynamoDB 資料庫的所有 GraphQL 操作的解析器代碼(resolver code)。

只需定義好 GraphQL schema Note 類型,現在,執行 push 命令部署 API:

amplify push
? Are you sure you want to continue? Yes
? Do you want to generate code for your newly created GraphQL API: Yes
? Choose the code generation language target: javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions: src\graphql\**\*.js
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions: Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested]: 2

部署完成後,將在你的帳戶中成功創建 API 和資料庫。 接下來,讓我們在 AWS 控制台中打開新創建的 AppSync API,並測試一些 GraphQL 操作。

AWS AppSync 控制台

你隨時可以打開 AWS AppSync 控制台:

amplify console api

這會在瀏覽器打開 AppSync 控制台,單擊左側菜單中的 Queries 以打開查詢編輯器。在這裡,你可以使用 API 測試 GraphQL queries、mutations、和 subscriptions。

首先,我們要用 mutation 新增一筆便箋資料。 在查詢編輯器中,執行以下的 mutation:

1
2
3
4
5
6
7
8
9
mutation createNote {
createNote(input: {
name: "流感疫苗"
description: "門診時順便施打疫苗"
completed: false
}) {
id name description completed
}
}

現在,您已經建立了一個項目,你可以嘗試查詢它。 讓我們查詢應用程序中的所有便箋:

1
2
3
4
5
6
7
8
9
10
query listNotes {
listNotes {
items {
id
name
description
completed
}
}
}

你也可以用 ID 查詢單筆便箋:

1
2
3
4
5
6
7
8
query getNote {
getNote(id: "<NOTE_ID>") {
id
name
description
completed
}
}

使用 AWS Serverless GraphQL API,定義好 GraphQL schema 後,我們沒有寫任何一行 GraphQL 代碼。現在 GraphQL API 已部署並且可以正常運行,讓我們開始編寫一些前端代碼。

構建前端 React 應用程序

我們需要做的第一件事是配置 React 應用程序以識別位於 src/aws-exports.js 的 Amplify 資源。 為此,請打開 src/index.js 並在最後一個 import 下面添加以下內容:

1
2
3
import Amplify from 'aws-amplify'
import config from './aws-exports'
Amplify.configure(config)
Listing Notes (GraphQL Query)

現在已經配置了應用程序,可以開始對 GraphQL API 進行調用。 我們將要執行的第一個操作將是 GraphQL query 以列出所有便箋。

這個查詢將返回一個陣列,我們將使用陣列的 map 映射所有項目,顯示便箋的 name,description 以及是否已經 completed。

在 src/App.js 中,首先將以下內容加入文件頂部:

1
2
3
4
5
import React, { useEffect, useReducer } from 'react'
import { API } from 'aws-amplify'
import { List } from 'antd'
import 'antd/dist/antd.css'
import { listNotes } from './graphql/queries'
  • useEffect and useReducer
    這是 React Hooks。可參考這裡
  • API
    這是我們將用於與 AppSync 端點進行互動的 GraphQL 客戶端(類似於 fetch 或 axios)。
  • List
    Ant Design 程式庫中的 UI 組件 以渲染呈現列表。
  • listNotes
    用於獲取便箋陣列的 GraphQL query 查詢操作。

接下來,我們將需要創建一個變數來保存我們的初始應用程序狀態。 因為我們的應用程序將保存並使用多個狀態變數,所以我們將使用 React 的 useReducer 掛鉤來管理狀態。

useReducer 具有以下 API:

const [state, dispatch] = useReducer(reducer <function>, initialState <any>)

useReducer 接受兩個參數,

  1. 類型為 (state, action) => newState 的 reducer 函式。
  2. initialState 初始值。

基本的用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* Example of some basic state */
const initialState = { notes: [] }

/* Example of a basic reducer */
function reducer(state, action) {
switch(action.type) {
case 'SET_NOTES':
return { ...state, notes: action.notes }
default:
return state
}
}

/* Implementing useReducer */
const [state, dispatch] = useReducer(reducer: <function>, initialState: <any>)

/* Sending an update to the reducer */
const notes = [{ name: 'Hello Tainan' }]
dispatch({ type: 'SET_NOTES', notes: notes })

/* Using the state in your app */
{
state.notes.map(note => <p>{note.name}</p>)
}

調用時,useReducer 掛鉤會返回一個包含兩個項目的陣列:

  1. state: 應用程序 state 變數並賦予初始狀態值。
  2. dispatch: 這是一個函式,此函式會調用 reducer 函式更新應用程序 state 變數值。

這裡的 Notes 應用程序的初始狀態將包含一個用於便箋的 notes,form values 表單值,error 錯誤和 loading 加載狀態的陣列。

在 src/App.js 中,在最後一個 import 之後添加 initialState 物件:

1
2
3
4
5
6
const initialState = {
notes: [],
loading: true,
error: false,
form: { name: '', description: ''}
}

然後創建 reducer。 目前,reducer 只具有設置 notes 陣列或設置錯誤狀態兩種事例:

1
2
3
4
5
6
7
8
9
10
function reducer(state, action) {
switch(action.type) {
case 'SET_NOTES':
return { ...state, notes: action.notes, loading: false };
case 'ERROR':
return { ...state, loading: false, error: true }
default:
return state
}
}

接下來,我們要更新 App 主函式。最後整個 src/App.js 會如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import React, { useEffect, useReducer } from 'react'
import { API } from 'aws-amplify'
import { List } from 'antd'
import 'antd/dist/antd.css'
import { listNotes } from './graphql/queries'

const initialState = {
notes: [],
loading: true,
error: false,
form: { name: '', description: ''}
}

function reducer(state, action) {
switch(action.type) {
case 'SET_NOTES':
return { ...state, notes: action.notes, loading: false };
case 'ERROR':
return { ...state, loading: false, error: true }
default:
return state
}
}

function App() {
const [state, dispatch] = useReducer(reducer, initialState)

useEffect(() => {
fetchNotes()
}, [])

async function fetchNotes() {
try {
const notesData = await API.graphql({
query: listNotes
})
dispatch({ type: 'SET_NOTES', notes: notesData.data.listNotes.items })
} catch (err) {
console.log('error: ', err)
dispatch({ type: 'ERROR' })
}
}

function renderItem(item) {
return (
<List.Item style={styles.item}>
<List.Item.Meta
title={item.name}
description={item.description}
/>
</List.Item>
)
}

return (
<div style={styles.container}>
<List
loading={state.loading}
dataSource={state.notes}
renderItem={renderItem}
/>
</div>
);
}

const styles = {
container: { padding: 20 },
input: { marginBottom: 10 },
item: {textAlign: 'left' },
p: { color: '#1890ff', cursor: 'pointer' }
}

export default App;
  • 第 26 行,調用 useReducer 並傳入 reducer 和 initialState 以創建 state 和 dispatch 變數。
  • 第 32 行,fetchNotes 函式將調用 AppSync API,並在 API 調用成功後於第 37 行以一個 action 物件當引數調用 dispatch 函式,用來重置 notes 陣列。
  • 第 28 行,透過 useEffect 掛鉤來調用 fetchNotes 函式初始化 notes 陣列。
  • 第 57 行,我們使用 Ant Design 的 List 組件。 該組件將映射到陣列(dataSource)上,並調用 renderItem 函式為陣列中的每個項目返回一個 UI 組件。
  • 第 66 行,為我們將用於此應用程序的組件創建一些樣式。

現在我們可以啟動 app 了!

yarn start

你應該會在瀏覽器上看到當前 notes 便箋列表。

Creating Notes (GraphQL Mutation)

現在你已經知道要如何查詢便箋列表,再來讓我們看一下如何新增便箋。我們需要:

  1. 用於建立新便箋的 HTML 表單。
  2. 當用戶輸入表單時更新狀態的函式。
  3. 將新便箋添加到 UI,並發送 API 調用 GraphQL API 以便將新便箋存入資料庫的函式。

首先,導入 UUID 程式庫,以便可以為客戶端創建唯一的標識符,以便稍後在實現訂閱時,我們可以識別創建便箋的客戶端。

我們還將從 Ant Design 導入 Input 和 Button 組件:

import { v4 as uuid } from 'uuid'
import { List, Input, Button } from 'antd'

接下來,您將需要導入 GraphQL createNote mutation 定義:

import { createNote as CreateNote } from './graphql/mutations'

然後在最後一個 import 下面創建一個新的 CLIENT_ID 變數:

const CLIENT_ID = uuid();

更新 reducer 函式中的 switch 語句以添加三個新案例。

  1. 向本地端的狀態添加新便箋
  2. 重置 HTML 表單狀態以清除表單
  3. 用戶輸入時更新表單狀態
case 'ADD_NOTE':
return { ...state, notes: [action.note, ...state.notes]}
case 'RESET_FORM':
return { ...state, form: initialState.form }
case 'SET_INPUT':
return { ...state, form: { ...state.form, [action.name]: action.value }}

接下來,在 App 主函式內創建 createNote 函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function createNote() {
const { form } = state
if (!form.name || !form.description) {
return alert('please enter a name and description')
}
const note = { ...form, clientId: CLIENT_ID, completed: false, id: uuid() }
dispatch({ type: 'ADD_NOTE', note })
dispatch({ type: 'RESET_FORM' })
try {
await API.graphql({
query: CreateNote,
variables: { input: note }
})
console.log('successfully created note!')
} catch (err ) {
console.log('error: ', err)
}
}

在此函式中,我們在第 10 行 API 調用成功之前先在第 7 行更新本地狀態。 這被稱為 optimistic response。 這樣做是因為我們希望用戶在添加新便箋後立即更新用戶界面。 如果 API 調用失敗,則可以在 catch 區塊中實現某些功能並通知用戶錯誤訊息。

現在,在 App 主函式內創建一個 onChange 處理函式,以在用戶輸入即時更新表單狀態:

1
2
3
function onChange(e) {
dispatch({ type: 'SET_INPUT', name: e.target.name, value: e.target.value })
}

最後,我們將更新 UI 以添加表單組件。 在 List 組件之前,添加以下兩個輸入和按鈕:

<Input 
onChange={onChange}
value={state.form.name}
placeholder="Note Name"
name='name'
style={styles.input}
/>
<Input
onChange={onChange}
value={state.form.description}
placeholder="Note description"
name='description'
style={styles.input}
/>
<Button
onClick={createNote}
type="primary"
>Create Note</Button>

現在,我們應該能夠使用該表單建立新的便箋。

Deleting Notes (GraphQL Mutation)

接下來,讓我們看一下如何刪除便箋。

首先,導入 deleteNote mutation。

import { 
createNote as CreateNote,
deleteNote as DeleteNote
} from './graphql/mutations'

然後,在 App 主函式內創建一個 deleteNote 函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function deleteNote({ id }) {
const index = state.notes.findIndex( n => n.id === id)
const notes = [
...state.notes.slice(0, index),
...state.notes.slice(index + 1)
];
dispatch({ type: 'SET_NOTES', notes })
try {
await API.graphql({
query: DeleteNote,
variables: { input: { id }}
})
console.log('successfully deleted note!')
} catch (err) {
console.log({ err })
}
}

現在,更新 renderItem 函式中的 List.Item 組件,以將將會調用 deleteNote 函式的刪除按鈕添加到 actions 屬性中,並傳入該要刪除的項目:

<List.Item 
style={styles.item}
actions={[
<p style={styles.p} onClick={() => deleteNote(item)}>Delete</p>
]}
>
<List.Item.Meta
title={item.name}
description={item.description}
/>
</List.Item>

現在,我們應該可以刪除便箋。

Updating Notes (GraphQL Mutation)

再來我們要添加更新便箋的功能。

首先,導入 updateNote mutation。

import { 
createNote as CreateNote,
deleteNote as DeleteNote,
updateNote as UpdateNote
} from './graphql/mutations'

然後,在 App 主函式內創建一個 updateNote 函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function updateNote(note) {
const index = state.notes.findIndex( n => n.id === note.id)
const notes = [...state.notes]
notes[index].completed = !note.completed
dispatch({ type: 'SET_NOTES', notes })
try {
await API.graphql({
query: UpdateNote,
variables: { input: { id: note.id, completed: notes[index].completed }}
})
console.log('note successfully updated!')
} catch (err) {
console.log('error: ', err)
}
}

最後,在 renderItem 函式中的 List.Item 組件新增一個更新按鈕。

<List.Item 
style={styles.item}
actions={[
<p style={styles.p} onClick={() => deleteNote(item)}>Delete</p>,
<p style={styles.p} onClick={() => updateNote(item)}>
{item.completed ? 'completed' : 'mark completed'}
</p>
]}
>

現在,我們應該能夠將便箋更新為 completednot completed

Real-Time Data (GraphQL Subscriptions)

最後,我們將實現的一個功能是 subscribe to updates in real time 即時訂閱。

我們要訂閱的是當有新便箋新增時,要讓我們的應用程序能夠即時接收到新便箋,更新 notes 陣列,然後將更新後的 notes 陣列呈現到螢幕上。

使用 GraphQL subscription,您可以訂閱不同的事件。 這些事件通常是某種類型的 mutation (on create、on update、on delete)。 當這些事件之一發生時,該事件的數據將發送到已初始化訂閱的客戶端,然後由你決定要如何處理送到客戶端的數據。

要讓訂閱可以運作,你僅需在 useEffect 掛鉤中初始化訂閱,並在觸發訂閱時將 ADD_NOTE 類型與便箋數據一起用 dispatch 發送出去。

首先,導入 onCreateNote subscription。

import { onCreateNote } from './graphql/subscriptions'

接下來,使用以下代碼更新 App 主函式內 useEffect 掛鉤:

1
2
3
4
5
6
7
8
9
10
11
12
13
useEffect(() => {
fetchNotes()
const subscription = API.graphql({
query: onCreateNote
}).subscribe({
next: noteData => {
const note = noteData.value.data.onCreateNote
if (CLIENT_ID === note.clientId) return
dispatch({ type: 'ADD_NOTE', note })
}
})
return () => subscription.unsubscribe()
}, [])

在此,我們訂閱了 onCreateNote 事件。 當有新便箋建立時,將觸發此事件,並用便箋數據作為引數調用 next 函式。

在 next 函式中我們獲取便箋數據,並檢查我們的客戶端是否是創建便箋的應用程序。 如果我們的客戶創建了便箋,我們將不做任何運作,避免同一筆資料重複加入 notes 陣列。如果我們不是創建便箋的客戶,則將調用 diapatch 執行 ADD_NOTE 操作,加入便箋數據。

現在你可以在瀏覽器開起兩個不同的客戶端,在一個客戶端新增一筆便箋數據,然後切換到另一個客戶端畫面,應該可以看到新增的便箋數據也顯示在螢幕上了。

恭喜,您已經建立了一個無伺服器 GraphQL 應用程序!

要記住以下幾點:

  • GraphQL queries 用於在 GraphQL API 中讀取數據。
  • GraphQL mutations 用於在 GraphQL API中 創建,更新或刪除數據。
  • GraphQL subscription 可以用來訂閱 GraphQL API 中的 API 即時事件。

使用 AWS Serverless GraphQL API 你可以只要定義 GraphQL schema,不用編寫任何一行的代碼,就可以有 CRUD + L (create,read,update,delete 和 list) 操作的 GraphQL API 與即時的訂閱事件。你只要專心在你客戶端應用程序的邏輯上,並且可以創造一個即時的現代化應用。

大多數應用程序的核心是 data/API 層。該層包含很多東西,在無伺服器世界中,這通常由 API 端點和無伺服器函式的組合而成。 這些無伺服器函式可能執行某些邏輯並返回數據、與某種資料庫進行互動,甚至與另一個 API 端點進行互動。

使用 Amplify 創建 API 有兩種主要方法:

  • Amazon API Gateway 和 Lambda 函式的組合。
  • 連接到某種類型的數據源(資料庫、Lambda 函式或 HTTP 端點)的 GraphQL API。

API Gateway 是一項 AWS 服務,可讓您創建 API 終端節點,並將它們透過 Lambda 函式路由到不同的服務。 當進行 API 調用時,它將透過 API Gateway 路由,調用 Lambda 函式,然後返迴結果。 使用 Amplify CLI,您可以創建 API Gateway 端點和 Lambda 函式,CLI 會自動將 API 配置為能夠透過 HTTP 請求調用 Lambda 函式。

創建 API 後,您需要一種與之互動的方法。 使用 Amplify client,您將能夠使用 Amplify API class 從客戶端將請求發送到 API Gateway 端點。 API class 允許你與 GraphQL API 以及 API Gateway 端點進行互動。

在這個範例中,您將創建第一個簡單的全棧無伺服器應用程序,這個應用程序將透過 API Gateway 端點與無伺服器函式進行互動。 你將會使用 CLI 創建 API 端點以及無伺服器函式,然後使用 Amplify 客戶端程式庫與 API 進行互動。 從這個範例我們將可以學習到無伺服器應用程序的基本運作。

創建和部署無伺服器函式

無伺服器函式(serverless functions)是許多無伺服器應用程序的核心。無伺服器函式在無狀態計算容器(stateless compute containers)中運行代碼,這些容器是事件驅動(event-driven)的、短暫的(short-lived,可能只持續一次調用),並由雲提供商完全管理。 這些函式可以動態的擴展,不需要擴增額外的伺服器。

大多數人認為無伺服器函式是透過 API 的調用而觸發的,但這些函式也可以由各種不同的事件觸發。 除 HTTP 請求外,一些無伺服器函式常會用在例如將圖像上傳存儲服務、資料庫的新增、更新、或刪除資料,甚至來自其他無伺務器函式。

無伺服器函式會自動擴展,因此,如果流量激增,則無需擔心你的應用程序效能。 第一次調用函式時,服務提供者將創建該函式的實例並運行其處理程序方法(handler method)以處理事件。 函式完成並返迴結果後,它將會被保留並繼續處理其他事件(如果有)。如果在第一個事件仍在處理過程中發生其他調用,則服務將創建另一個實例。

無伺服器函式還具有不同於傳統基礎架構的支付模型。 像 AWS Lambda 之類的服務,您只需對根據函式的請求數量以及代碼執行所耗的時間付費。 這與其他基礎架構之類的服務(無論是否正在使用它們)不同。

現在了解了無伺服器函式的運作概念,我們要來創建一個無伺服器函式,並將其掛接到 HTTP API。

創建 React 應用程序及安裝所需的程式庫

首先需要創建客戶端 React 應用程序:

yarn create react-app amplify-react-api-app

cd amplify-react-api-app

接下來,需要安裝依賴項。對於此應用程序,只需要 AWS Amplify 程式庫:

yarn add aws-amplify

安裝依賴項後,現在可以在 React 應用程序的根目錄中初始化一個新的 Amplify 專案:

amplify init
? Enter a name for the project cryptoapp
? Enter a name for the environment local
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using react
? Source Directory Path: src
? Distribution Directory Path: build
? Build Command: npm.cmd run-script build
? Start Command: npm.cmd run-script start
Using default provider awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default

現在,已經創建了 Amplify 專案和 React 應用程序,可以開始添加新功能。

創建新的無伺服器函式

下一步,我們將創建將用於此應用程序的無伺服器函式。 在本範例中我們要構建一個加密貨幣應用程序。 首先,將在函式中使用一組硬編碼的加密貨幣的陣列資料,然後將其返回給客戶端。 稍後,我們將更新此函式以調用實際的 API(CoinLore),以異步方式獲取即時的加密貨幣現值。

要創建無伺服器函式,執行以下命令:

amplify add function
? Select which capability you want to add: Lambda function (serverless function)
? Provide a friendly name for your resource to be used as a label for this category in the project: cryptofunction
? Provide the AWS Lambda function name: cryptofunction
? Choose the runtime that you want to use: NodeJS
? Choose the function template that you want to use: Serverless ExpressJS function (Integration
with API Gateway)
? Do you want to access other resources in this project from your Lambda function? No
? Do you want to invoke this function on a recurring schedule? No
? Do you want to configure Lambda layers for this function? No
? Do you want to edit the local lambda function now? (Y/n) No

現在在專案的根目錄下會產生一個新的子文件夾 amplify,在 amplify 目錄中會看到一個新的子文件夾: amplify/backend/function/cryptofunction

無伺服器函式代碼

創建此資源時,會在 amplify/backend 中創建了一個 function 的新文件夾。 CLI 創建的所有函式都將存儲在此文件夾中。 目前,您只有一個函式,cryptofunction 。 在crytpofunction 文件夾中,您將看到幾個配置文件以及主要功能代碼所在的 src 目錄。

無伺服器函式本質上僅是獨立運行的封裝應用程序。 由於您創建的函式是使用 JavaScript 編寫的,因此你會看到在所有 JavaScript 應用程序中通常都會看到的所有內容,包括 package.json 和 index.js。

接下來,看一下位於 cryptofunction 文件夾中 src/index.js 的函式入口點。

1
2
3
4
5
6
7
8
9
const awsServerlessExpress = require('aws-serverless-express');
const app = require('./app');

const server = awsServerlessExpress.createServer(app);

exports.handler = (event, context) => {
console.log(`EVENT: ${JSON.stringify(event)}`);
return awsServerlessExpress.proxy(server, event, context, 'PROMISE').promise;
};

在此文件中,您將看到一個名為 exports.handler 的函式。 這是無伺服器函式調用的入口點。 調用該無伺服器函式時,這就是運行的代碼。

您可以根據需要直接在此函式中處理事件,但是由於這裡將使用 API,因此更好的方式是透過代理使用 Express 的路由。 這樣做可以在一個函式中提供多個路由,每個路由提供不同的 HTTP 請求方法,例如 get、put、post 、和 delete。 無伺服器框架提供了一種簡便的方法,並且已為您在 src/app.js 文件中內置了函式樣板。

在 index.js 中,你看到的代碼:

awsServerlessExpress.proxy(server, event, context, 'PROMISE').promise;

此代碼就是將事件,上下文和路徑代理到在 app.js 中運行的 Express 伺務器的地方。

然後,在 app.js 中,你將能夠針對為 API 創建任何的 HTTP 請求路由。

創建 /coins 路由

現在了解了應用程序的結構,讓我們在 app.js 中創建一個新的路由,並返回一些數據。 將創建的路由是 /coins 路由。 此路由將返回包含 coins 陣列的物件。

開啟 amplify/backend/function/cryptofunction/src/app.js,在第一個 app.get(‘/items’) 路由之前,添加以下代碼:

1
2
3
4
5
6
7
8
9
app.get('/coins', function(req, res) {
const coins = [
{name: 'Bitcoin', symbol: 'BTC', price_usd: "10000"},
{name: 'Ethereum', symbol: 'ETH', price_usd: "400"},
{name: 'Litecoin', symbol: 'LTC', price_usd: "150"}
];

res.json({ coins });
});

這裡暫時使用一組硬編碼的加密貨幣信息陣列。 使用此路由調用該函式時,它將以一個物件方式作為回應,該物件包含一個名為 coins 的屬性。

創建一個新的 API

創建並配置了函式後,現在要在其前端放置一個 API,以便你可以透過 HTTP 請求來觸發它。

為此,我們將使用 Amazon API Gateway。 API Gateway 是一項完全託管的服務,使開發人員能夠創建、發佈、維護、監控、和保護 REST 與 WebSocket API。 Amplify CLI 和 Amplify client 程式庫都有支援 API Gateway。

在這裡,我們會將創建一個新的 API Gateway 端點並將其配置為調用之前所創建的 Lambda 函式。

要創建 API,可以從專案的根目錄使用 Amplify add 命令:

amplify add api
? Please select from one of the below mentioned services: REST
? Provide a friendly name for your resource to be used as a label for this category in the project: cryptoapi
? Provide a path (e.g., /book/{isbn}): /icons
? Choose a Lambda source: Use a Lambda function already added in the current Amplify project
? Choose the Lambda function to invoke by this path: cryptofunction
? Restrict API access: No
? Do you want to add another path? No

部署 API 和 Lambda 函式

現在已經創建了函式和 API,現在需要將它們部署到你的帳戶中。 為此,要執行 Amplify push 命令:

amplify push
√ Successfully pulled backend environment local from the cloud.

Current Environment: local

| Category | Resource name | Operation | Provider plugin |
| -------- | -------------- | --------- | ----------------- |
| Function | cryptofunction | Create | awscloudformation |
| Api | cryptoapi | Create | awscloudformation |
? Are you sure you want to continue? Yes
...
...
√ All resources are updated in the cloud

REST API endpoint: https://k1zvup7xx7.execute-api.ap-southeast-1.amazonaws.com/local

您可以隨時使用 Amplify CLI status 命令來查看專案的當前狀態。status 命令將列出專案中所有當前配置的服務並為您提供每個服務的狀態:

amplify status
Current Environment: local

| Category | Resource name | Operation | Provider plugin |
| -------- | -------------- | --------- | ----------------- |
| Function | cryptofunction | No Change | awscloudformation |
| Api | cryptoapi | No Change | awscloudformation |

REST API endpoint: https://k1zvup7xx7.execute-api.ap-southeast-1.amazonaws.com/local

在此狀態輸出中要注意的主要是 OperationOperation 告訴你下次在專案中執行 push 時將發生什麼。 Operation 屬性會設置為 CreateUpdateDelete、或 No Change

這裡也顯示了我們創建的 REST API 端點,你可以測試一下我們創建的 /coins 路由:

https://k1zvup7xx7.execute-api.ap-southeast-1.amazonaws.com/local/coins

與 API 互動

現在伺服端已經部署了資源,可以開始從客戶端 React 應用程序與 API 進行互動。

要在任何應用程序中使用 Amplify client 客戶端程式庫,通常需要在應用程序最上層 root 層級進行基本配置。

在創建資源時,CLI 會將有關的資源信息記錄在 aws-exports.js 文件中。 我們將使用此文件來配置客戶端應用程序以便能與 Amplify 架構一起使用。

要配置該應用,請打開專案根目錄下的 src/index.js,並將以下內容添加到最後一個 import 後面:

src/index.js
1
2
3
import Amplify from 'aws-amplify';
import config from './aws-exports';
Amplify.configure(config);
Amplify Client API 類別

在配置客戶端應用程序之後,您可以開始與資源進行互動了。

Amplify client 客戶端程式庫具有各種 API 類別,可以將其導入並用於各種類型的函式中,包括用於身份驗證的 Auth,用於 S3 中存儲項目的 Storage 以及用於與 REST 和 GraphQL API 進行互動的 API。

在這裡,您將使用 API 類別。 API 有多種可用方法,包括用於與 REST API 互動的 API.get、API.post、API.put、與 API.del。以及用於與 GraphQL API 互動的 API.graphql。

使用 REST API 時,API 包含三個參數:

API.get(apiName: String, path: String, data?: Object)
  • apiName
    從創建 API 時提供的名稱。 在我們的範例中,將是 cryptoapi。
  • path
    要與之互動的路徑。 在我們的範例中,將是 /coins。
  • data
    這是一個可選的物件,其中包含你要傳遞給 API 的所有屬性,包括 headers、query 字符串參數、或 body。

在我們的範例中,API 調用將如下所示:

API.get('cryptoapi', '/coins')

API 返回一個 promise,這意味著您可以使用 promise 或 async 函式處理所有內容。

API.get('cryptoapi', '/coins')
.then(data => console.log(data))
.catch(error => console.log(error))

const data = await API.get('cryptoapi', '/coins')

接下來,讓我們從客戶端調用 API 並渲染數據。 修改專案根目錄下的 src/App.js:

src/App.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import React, { useState, useEffect } from 'react'
import { API } from 'aws-amplify'
import './App.css';

function App() {
const [coins, updateCoins] = useState([])

async function fetchCoins() {
const data = await API.get('cryptoapi', '/coins')
updateCoins(data.coins)
}

useEffect(() => {
fetchCoins()
}, [])

return (
<div className="App">
{
coins.map((coin, index) => (
<div key={index}>
<h2>{coin.name} - {coin.symbol}</h2>
<h5>${coin.price_usd}</h5>
</div>
))
}
</div>
);
}

export default App;

然後,啟動 app:

yarn start

現在你可以從瀏覽器看到:

調用另一個 API

接下來,我們將更新 AWS Lambda 函式以調用另一個 API,即 CoinLore API,它將從 CoinLore 服務返回動態的數據。使用者也將能夠設置過濾器,例如 limitstart,限制從 API 返回的項目數量。

首先,你需要一種可以在 Lambda 函式中與 HTTP 端點進行互動的方法。 在本範例中要使用的是 Axios 程式庫。 Axios 是可用於瀏覽器和 Node.js 且是基於 Promise 的 HTTP 客戶端程式庫。

您需要做的第一件事是在 function 文件夾中安裝 Axios 程式庫,以便從 Lambda 函式發送 HTTP 請求。 切換到 amplify/backend/function/cryptofunction/src 目錄,安裝 Axios,然後切換回應用程序的根目錄:

$ cd amplify/backend/function/cryptofunction/src
$ yarn add axios
$ cd ../../../../../

接下來,更新 amplify/backend/function/cryptofunction/src/app.js 中的 /coins 路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const axios = require('axios')

app.get('/coins', function(req, res) {
let apiUrl = `https://api.coinlore.com/api/tickers/?start=0&limit=10`;

if (req.apiGateway && req.apiGateway.event.queryStringParameters) {
const { start = 0, limit = 10 } = req.apiGateway.event.queryStringParameters;
apiUrl = `https://api.coinlore.com/api/tickers/?start=${start}&limit=${limit}`
}

axios.get(apiUrl)
.then(response => {
res.json({ coins: response.data.data })
})
.catch(err => res.json({ error: err }))
});

在前面的函式中,我們導入了 Axios 程式庫,然後對 CoinLore API 進行了 API 調用。 在 API 調用中,你可以將 start 和 limit 參數傳遞給請求,以定義要返回的項目數量以及起始點。

在 req 參數中,有一個 apiGateway 屬性,用於保存事件(event)和上下文(context)變數。 在函式中,檢查該事件是否存在以及該事件的 queryStringParameters 屬性。 如果 queryStringParameters 屬性存在,我們將使用這些值來更新基本的 URL。 使用 queryStringParameters,用戶可以在查詢 CoinLore API 時指定起始值和極限值。

要注意,這裡修改的是伺服器端的函式,使用的是 Node.js 環境,導入程式庫用的是 require 函式。

函式更新後,在終端中執行 push 命令來部署更新:

amplify push
√ Successfully pulled backend environment local from the cloud.

Current Environment: local

| Category | Resource name | Operation | Provider plugin |
| -------- | -------------- | --------- | ----------------- |
| Function | cryptofunction | Update | awscloudformation |
| Api | cryptoapi | No Change | awscloudformation |
? Are you sure you want to continue? Yes
更新客戶端

現在,我們要更新客戶端的 React 應用程序,為用戶提供指定 limit 和 start 參數的選項。

為此,您需要添加用於用戶輸入的字段(field),並為用戶提供一個按鈕以觸發新的 API 請求。

更改專案根目錄下的 src/App.js:

function App() {
const [coins, updateCoins] = useState([])
const [input, updateInput] = useState({ limit: 5, start: 0 });

function updateInputValues(type, value) {
updateInput({ ...input, [type]: value });
}

async function fetchCoins() {
const { limit, start } = input;
const data = await API.get('cryptoapi', `/coins?limit=${limit}&start=${start}`)
updateCoins(data.coins)
}

useEffect(() => {
fetchCoins()
}, [])

return (
<div className="App">
<input
placeholder="limit"
onChange={e => updateInputValues('limit', e.target.value)}
/>
<input
placeholder="start"
onChange={ e => updateInputValues('start', e.target.value)}
/>
<button onClick={fetchCoins}>Fetch Coins</button>
<div>
{
coins.map((coin, index) => (
<div key={index}>
<h2>{coin.name} - {coin.symbol}</h2>
<h5>${coin.price_usd}</h5>
</div>
))
}
</div>
</div>
);
}

然後,啟動 app:

yarn start

恭喜,你已經部署了第一個無伺務器 API!

這裡要牢記以下幾點:

  • Lambda 函式可以從各種事件中觸發。 在此範例中,我們使用來自 API Gateway 的 API 調用觸發了該函式。
  • 可以使用 Amplify CLI 的指令 amplify add function 創建 Lambda 函式,並且可以使用 amplify add api 創建 API。
  • 可以將單個 API Gateway 端點配置為與多個 Lambda 函式一起使用。 在此範例中,我們僅將其連接到單個函式。
  • Lambda 函式本質上是獨立的 Node.js 應用程序。 在此範例中,我們選擇 Express 應用程序來處理諸如 get、post、和 delete 之類的 REST 方法。在這個範例中我們僅使用了 get 調用。
  • Amplify client 客戶端程式庫中的 API 類別可同時用於 GraphQL 以及 REST API。

人們通常將雲計算與後端(backend)開發和開發與維運(DevOps)相關聯。但是,在過去幾年中,這種情況已經開始改變。隨著功能即服務(functions as a service, FaaS)的興起以及強大抽象式的託管服務,雲提供商降低了雲計算新手和傳統前端開發人員的進入門檻。

使用現代工具、框架和服務,例如 Amazon Web Services(AWS)Amplify 和 Firebase 等,獨立的開發人員可以利用他們現有的技能,以及對單個框架和生態系統(例如 JavaScript)的知識,來構建過去需要技術熟練的後端團隊和 DevOps 工程師團隊來構建和維護的全棧應用程序,而無須擔心伺服器的問題。

現代的無伺服器概念

Serverless 通常與 FaaS 相關聯。儘管您會對其含義有所不同的定義,但該術語最近已包含更多的意義而不只是共享的定義。

很多時候,人們談論無伺服器時,他們實際上是在描述如何最有效地編寫業務邏輯以呈現業務的價值。重點是編寫業務邏輯,而不是為編寫業務邏輯而必須建構和維護的基礎設施。採用 FaaS 的思維,您可以快速有效的做到這一點。 你不需要其他的託管服務和一些抽象化的思維,也可以構建自定義的解決方案。

越來越多的公司和開發人員正在採用這種方法,隨著這種哲學的日益普及,初創企業和雲提供商可以使用提供的服務和工具來提供簡化後端複雜性的產品和服務。

對於無伺服器在學術研究中認為這是一種 “簡化的雲編程”:

雖然雲將功能打包為 FaaS(功能即服務),代表了無伺服器計算的核心,但雲平台還提供了專門的無伺服器框架,可像 BaaS(後端即服務)一樣滿足特定的應用需求。 簡單地說,無伺服器計算 = FaaS + BaaS。

後端即服務(BaaS)通常是指託管服務,例如資料庫(Firestore,Amazon DynamoDB)、身份驗證服務(Auth0,Amanzon Cognito)和人工智能服務(Amanzon Relognition,Amazon Comprehend)等託管服務。在學術的研究中重新定義了無伺服器的含義,這突顯了隨著雲提供商開始構建更多,管理更好的服務。這種無伺服器的思維正在蓬勃的發生中。

無伺服器應用程序的特徵

  • 降低運營責任
    使用 FaaS,您唯一需要擔心的是代碼中的函式是否正確的運行。不再需要負責伺服器的所有修補,更新,維護和升級。
  • 大量使用託管服務

無伺服器架構的好處

  • 可擴展性
    您不必擔心你的應用程序突然湧入了大量的新用戶,雲提供商將為您處理此事。
  • 成本
    使用無伺服器技術,您只需為使用的東西付費。使用 FaaS,將根據對功能的請求數,執行功能代碼所花費的時間以及每個功能使用的記憶體來向您收費。
  • 開發速度
    開發人員只需構建較少的功能,提高工作效率。開發人員不必費心在大多數應用程序所需的典型功能類型,例如,資料庫,身份驗證,存儲和 API,可以快速專注於編寫要交付的核心功能和業務邏輯。
  • 實驗與測試
    如果您沒有花大量的時間構建一些典型所需的重複功能,就可以低風險輕鬆地進行實驗及測試。
  • 安全穩定
    你所使用的核心服務是由雲提供商所提供,所以通常比您自己建立的更加穩定和安全。
  • 更少的代碼
    代碼是一種責任。有價值的是代碼提供的功能,而不是代碼本身。當您找到實現這些功能的方法,同時限制了您需要維護的代碼量,甚至完全取消了代碼時,您就可以降低應用程序的整體複雜性。複雜性降低,錯誤減少,新工程師更容易上手,維護和添加新功能的人員的總體負載也更少。開發人員可以專注於功能的實現,而無需了解實際的後端運作,並且幾乎不需要編寫後端代碼。

不同的無伺服器的實現

  • Serverless Framework

    最早與最受歡迎的的無伺務器框架 Severless Framework,是一個免費開放源代碼框架,於2015年10月以 JAWS 的名稱啟動,使用 Node.js 編寫。最初,這個框架僅支援 AWS,但隨後又增加了對 Google 和 Microsoft Azure 等雲提供商的支援。

    Serverless Framework 利用配置文件,CLI 和功能代碼的組合,為希望從本地環境將無伺服器功能和其他 AWS 服務部署到雲的人們提供良好的體驗。要使用此框架可能會有一些陡峭的學習曲線,特別是對於不熟悉雲計算的開發人員而言,有很多術語需要學習,還有很多知識需要理解雲服務如何工作,以便構建不僅僅是 “Hello World” 的應用程序。

    總體而言,如果您某種程度上了解除了 AWS 之外的雲提供商的能力,那麼 Serverless Framework 是一個不錯的選擇。

  • The AWS Serverless Application Model

    AWS Serverless Applicaton Model(AWS SAM) 是一個開源框架,於2016年11月18日發布,由 AWS 和社區構建和維護。 該框架僅支援 AWS。

    SAM 允許您透過在 YAML 文件中定義無伺務器應用程序所需的 API Gateway APIs,AWS Lambda 函式和 Amazon DynamoDB 來構建無伺服器應用程序。它結合使用 YAML 配置和功能代碼以及 CLI 來創建、管理和部署無伺務器應用程序。

  • Amplify Framework

    Amplify Framework 是四項內容的組合:CLI、客戶端程式庫、工具鍊和 Web 託管平台。Amplify 的目的是為開發人員提供一種簡便的方法來構建和部署利用雲的全棧 Web 和移動應用程序。它不僅啟用無伺務器功能和身份驗證等功能,而且還啟用 GraphQL API、機器學習(ML)、存儲、分析、推送通知等功能。

    Amplify 消除了 AWS 的新手可能不熟悉的術語,使用類別名稱方法來引用服務,從而提供了一個輕鬆進入雲的切入點。它不是將身份驗證服務稱為 Amazon Cognito,而是稱為 auth,避免新手搞得一頭霧水。

Full Stack Serverless on AWS

Full stack serverless 旨在為開發人員提供前、後兩端全棧所需的一切,以實現他們盡快構建應用程序的目標。在這裡,我們將使用 AWS 工具和服務以這種方式構建應用程序。

Amplify CLI

如果您剛開始使用 AWS,那麼大量的服務可能會讓你卻步。除了要識別許多服務之外,每個服務通常都有自己的陡峭的學習曲線。為了緩解這種情況,AWS 創建了 Amplify CLI。

Amplify CLI 為希望在 AWS 上構建應用程序的開發人員提供了一個簡單的入口點。 CLI 允許開發人員直接從其前端環境創建、配置、更新和刪除雲服務。

CLI 使用諸如 storage(Amazon S3)、auth(Amazon Cognito)和 analytics(Amazon Pinpoint)之類的名稱為您提供了一種了解服務實際功能的方法,而不僅僅是提供讓人迷惑的服務名稱。

Amplify client

構建全棧應用程序需要客戶端工具和後端服務的結合。過去,與 AWS 服務互動的主要方式是使用 AWS 軟件開發套件(SDK),例如 Java、.NET、Node.js、和 Python。 這些 SDK 運作良好,但沒有一個特別適合客戶端開發。在 Amplify 之前,還沒有使用 AWS 構建客戶端應用程序的簡單方法。如果查看 AWS Node.js SDK 的文件,您會了解到,這是一條陡峭的學習曲線。

Amplify 客戶端是一個程式庫,專門為需要與 AWS 服務互動的 JavaScript 應用程序提供了易於使用的 API。 Amplify 還提供用於 React Native、iOS 和 Addroid 的客戶端 SDK。

Amplify 還為流行的前端和移動框架提供 UI 組件,包括 React、React Native、Vue、Angular、Ionic、Android 和 iOS。

Amplify Framework 不支持整套 AWS 服務。相反的,它支持要開發無伺務器類型的應用程序所需要的服務。使用 Amplify,為與 EC2 的互動提供支援並沒有多大意義,但為 REST 和 GraphQL API 提供支援則會比較實際。

AWS AppSync

AWS AppSync 是使用 GraphQL 的託管 API 層,使應用程序易於與任何數據源,REST API 或微服務進行互動。

API 層是應用程序最重要的部分之一。現代應用程序通常與大量後端服務和 API 進行互動。諸如資料庫、託管服務、第三方 API 和存儲解決方案之類的東西。

微服務架構是使用模塊化組件或服務組合構建的大型應用程序的常用術語。大多數服務和 API 都有不同的實現細節,這在您使用微服務體系結構時會帶來一些挑戰。這導致不一致的代碼,有時甚至是混亂的代碼,以及對這些 API 發出請求的前端開發人員的認知負荷。

使用微服務體系結構的一種好方法是提供一個一致的 API 層,該 API 層將接收所有請求並將其轉發到後端服務。這為你的客戶提供了一個一致的互動層,從而使您的前端開發更加輕鬆。

GraphQL 為創建 API 提供了特別好的抽象層。 GraphQL 引入了一種定義和一致的規範,透過三種操作形式與 API 進行互動:queries(讀取)、mutations(寫入/更新)和 subscriptions(實時數據 real-time data)。這些操作被定義為主要模式的一部分,該主要模式還以 GraphQL 提示的形式提供了客戶端和伺務器之間的協定。GraphQL 操作未綁定到任何特定的數據源,因此,作為開發人員,您可以自由地使用它們與資料庫、HTTP 端點、微服務、甚至是無伺服器功能中的任何內容進行互動。

通常,在構建 GraphQL API 時,您需要處理構建,部署維護和配置自己的 API 的問題。借助 AWS AppSync,您可以卸載伺服器和 API 管理以及使用 AWS 的安全性機制。

現代應用程序通常還涉及 real-time 和 offline 等問題。 AppSync 的另一個好處是,它具有對 offline(Amplify client)和 real time(GraphQL subscriptions)的內建支援,以使開發人員能夠構建這些類型的應用程序。

初步認識 AWS Amplify CLI

安裝和設定 Amplify CLI

要使用 Amplify CLI,首先需要安裝 Node.js 10 以上的版本,以及 npm 5 以上的版本。也可以選擇使用 yarn。 

當然也需要一個 AWS 帳戶,你可以選擇從免費的帳戶開始,會要求綁定信用卡,註冊費 1 美元。當你沒有用超過免費的額度,不會收取任何費用。在私人的一般學習的專案應該都不會超過免費的額度。

首先安裝 Amplify CLI:

$ npm install -g @aws-amplify/cli

安裝 CLI 後,我們將會使用 Amplify CLI 與 AWS 服務互動,這需要用戶憑證。我們不會使用註冊的 AWS 帳戶來連結 AWS 服務,這個註冊的 AWS 帳戶只用來付帳單或登入 AWS 管理用。程式、API 等與 AWS 服務的互動我們要使用 IAM (identify and access management) user。所以我們首先需要使用 IAM 用戶來設定 Amplify CLI。這將會使用一組用戶憑據(accesss key ID 和 secret access key)來設定 CLI。 使用這些憑證,您將能夠直接從 CLI 使用這個 IAM 用戶創建 AWS 服務。

要創建 IAM 新用戶並設定 CLI,要執行 configure 指令:

$ amplify configure

這將引導你完成以下步驟:

  1. 指定 AWS 區域
    您可以選擇要在那區域個創建 IAM 用戶(以及與此用戶相關聯的服務)。選擇離您最近的區域或你偏好的區域。
Scanning for plugins...
Plugin scan successful
Follow these steps to set up access to your AWS account:

Sign in to your AWS administrator account:
https://console.aws.amazon.com/
Press Enter to continue

這會開啟瀏覽器要求你登入 AWS:

回到 CLI 操作畫面,按 Enter 繼續,選擇 AWS 區域,這裡我選擇使用 ap-southeast-1 位於新加坡:

Specify the AWS Region
? region:
eu-central-1
ap-northeast-1
ap-northeast-2
> ap-southeast-1
ap-southeast-2
ap-south-1
ca-central-1
(Move up and down to reveal more choices)
  1. 指定 IAM 用戶名

    該名稱將是您的 AWS 帳戶中創建的 IAM 用戶。我建議你使用一個稍後在引用時可以識別的名稱,例如 amplify-cli-ap-southeast-1-user 或 mycompany-cli-admin。

Specify the username of the new IAM user:
? user name: pec-amplify-cli-admin

輸入名稱後,CLI 將打開 AWS IAM dashboard。我們可以在這個 dashboard 建立 IAM 用戶。

IAM 用戶的權限全都透過 Groups 群組設定,所以首先先建立一個群組:

這裡我建立一個 admin 群組,權限選擇 AdministratorAccess。剛開始,所以給較大的權限,有經驗後再來建立其他的群組與權限。

現在創建 IAM 用戶。Access type 勾選 Programmatic access。

你可以透過單擊 Next 接受 Permissions、Tags、Reviews 等預設值並創建 IAM 用戶。在下一個畫面中,將會提供 IAM 用戶憑據:Access key ID 和 Secret access key。

返回 CLI 操作畫面,複製貼上 access key ID 和 secret access key。

Enter the access key of the newly created user:
? accessKeyId: # YOUR_ACCESS_KEY_ID
? secretAccessKey: # YOUR_SECRET_ACCESS_KEY
This would update/create the AWS Profile in your local machine
? Profile Name: # (default)

Successfully set up the new user.

現在,您已經成功設定了 CLI,可以開始創建新服務了。

初始化第一個 Amplify 專案

現在已經安裝和設定了 CLI,可以開始創建第一個專案。在接下來序列的範例中,客戶端我都會使用 React 架構,API 則會使用 GraphQL。

之前在 Oracle APEX 中學習到的 React 技術正好可以用在這裡。你也可以學習到創建一個 React 所使用的工具以及如何部署。

所以這裡我們將從初始化一個新的 React 專案開始:

npx create-react-app first-amplify-app

我則習慣使用 yarn:

yarn create react-app first-amplify-app

創建 React 專案後,切換到所產生的專案目錄。

cd first-amplify-app

你可以瀏覽一下 React 專案目錄的結構。

現在啟動 React 專案應用程序開發伺服器,看看初始專案是甚麼樣子。

npm start 

或用 yarn:

yarn start 

React 專案初始畫面:

關掉 React 應用程序開發伺服器。

現在,需要在 React 客戶端上安裝 Amplify。我們將安裝 AWS Amplify 程式庫與適用於 React 特定 UI 組件的 AWS Amplify React 程式庫。

yarn add aws-amplify @aws-amplify/ui-react

接下來,你可以創建一個 Amplify 專案。 執行 init 命令初始化 Amplify 專案:

amplify init

這將引導您完成以下步驟:

  1. Enter a name for the project.
    這將是專案的本地名稱,通常是用來描述專案是什麼或做什麼的。

  2. Enter a name for the environment.
    這將是您將要使用的初始環境的參考。典型環境可能是諸如 devlocalprod 之類的,但可以是對你有意義的任何名稱。

  3. Choose your default editor.
    這將設置您的編輯器。 CLI 稍後將使用此編輯器打開專案中的文件。

  4. Choose the type of app that you’re building.
    如果你使用 JavaScript,這將確定 CLI 是否應配置、構建和運行命令。在此範例中,選擇 javascript

  5. What JavaScript framework are you using?
    這將確定一些基本的構建和啟動命令。對於此範例,請選擇 react

  6. Source Directory Path.
    這使您可以設置源代碼所在的目錄。對於此範例,請選擇 src

  7. Distribution Directory Path.
    對於 Web 專案,這將是包含編譯的 JavaScript 源代碼以及你的 favicon 圖標、HTML、和 CSS 文檔的文件夾。對於此範例,選擇 build

  8. Build Command.
    這指定用於編譯和捆綁 JavaScript 代碼的命令。 對於此範例,請使用 npm run-script build

  9. Start Command.
    這指定在本地端啟動應用程序開發伺服器的命令。對於此範例,請使用 npm run-script start

  10. Do you want to use an AWS profile
    在這裡,選擇 Y,然後選擇你先前用 amplify configure 設定的的 AWS profile。

這是這個範例初始化的過程:

? Enter a name for the project firstamplifyapp
? Enter a name for the environment local
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using react
? Source Directory Path: src
? Distribution Directory Path: build
? Build Command: npm.cmd run-script build
? Start Command: npm.cmd run-script start
Using default provider awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default

現在,Amplify CLI 將初始化您的新 Amplify 專案。初始化完成後,將會在專案中為您創建兩個資源: 位於 src 目錄中的 aws-exports 的文件檔和位於專案根目錄中的名為 amplify 的文件夾。

  • aws-exports
    aws-exports 文件是 CLI 根據憑據所建立的 key-value 鍵值對資源類別。

  • amplify 目錄
    此文件夾包含 Amplify 專案的所有代碼和配置文件。 在此文件夾中,您將看到兩個子文件夾: backend#current-cloud-backend

    • backend 目錄
      此文件夾包含專案的所有本地代碼,例如 AppSync API 的 GraphQL schema,任何無伺服器功能的源代碼以及 Amplify 專案當前本地狀態的代碼的基礎結構。

    • #current-cloud-backend 目錄
      該文件夾保存的代碼和配置反映了最後一個 Amplify push 命令在雲中部署了哪些資源。它可幫助 CLI 區分雲中已配置的資源配置和本地 backend 目錄中的當前配置有甚麼不同(反映本地端的更改)。

現在,您已經初始化了專案,你可以添加第一個雲服務:身份驗證 (authentication)。

創建和部署雲服務

要創建新服務,可以使用 Amplify 的 add 命令:

amplify add auth

這將引導您完成以下步驟:

  1. Do you want to use the default authentication and security configuration?
    這使您可以選擇使用預設的配置(sign-up 使用 MFA,sign-in 使用 password)創建身份驗證服務(authentication service)、或使用 social providers 創建身份驗證配置、或者創建完全自定義的身份驗證配置。 對於此範例,請選擇 Default configuration

    MFA
    多重要素驗證(Multi-factor authentication,縮寫為 MFA),又譯多因子認證、多因素驗證、多因素認證,是一種電腦存取控制的方法,用戶要通過兩種以上的認證機制之後,才能得到授權,使用電腦資源。

  2. How do you want users to be able to sign in?
    這將允許您指定所需的 sign-in 屬性。 對於此範例,選擇預設值 Username

  3. Do you want to configure advanced settings?
    這將使您逐步設置一些更高階的認證,例如其他的 sign-up 屬性和 Lambda 觸發器。在此範例中,您不需要任何這些,因此請選擇 No, I am done. 接受預設的設置。

    現在,您已經成功配置了身份驗證服務,現在可以進行部署了。 要部署身份驗證服務,可以執行 push 命令:

    amplify push
  1. Are you sure you want to continue?
    選擇 Y。

部署完成後,您的身份驗證服務已成功創建。 恭喜,您已經部署了第一個功能。 現在,讓我們對其進行測試。

在 React 應用程序中有幾種與身份驗證服務互動的方法。你可以使用 Amplify 中的 Auth class,該類具有 30 多種可用方法,諸如 signUp、signIn、signOut 等方法。也可以使用特定的框架組件,例如 withAuthenticator 來構建完整的身份驗證流程,包括預配置的用戶界面。讓我們試用 withAuthenticator 高階(HOC higher-order)組件。

首先,在 React 應用程序中配置 Amplify。 打開 src/index.js 並將以下代碼添加到最後一個 import 語句下面。

import Amplify from 'aws-amplify'
import config from './aws-exports'
Amplify.configure(config)

現在,該應用已配置完畢,您可以開始與身份驗證服務進行互動。打開 src/App.js 並使用以下代碼更新文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react';
import { withAuthenticator, AmplifySignOut } from '@aws-amplify/ui-react'

function App() {
return (
<div style={{ width: "500px", textAlign: "center", margin: "auto" }}>
<h1>Hello Tainan, 台南!</h1>
<AmplifySignOut />
</div>
);
}

export default withAuthenticator(App);

現在,可以啟動應用程序進行測試:

yarn start

現在,你的應用程序已啟動了預配置的身份驗證流程: sign-up 使用 MFA,sign-in 使用 password。

使用 Create account 建立一個新帳戶:

MFA 身份驗證流程,你需要到你的 email 取得驗證碼。

輸入驗證碼 (Comfirmation code):

現在使用新的 User account sign-in:

刪除資源

如果單一的服務或專案已不再需要,可以使用 CLI 刪除它。

要刪除單一的功能,可以執行 remove 命令。

amplify remove auth

要刪除整個 Amplify 專案以及帳戶中已部署的所有相應資源,可以執行 delete 命令。

amplify delete

隨著越來越多的公司開始依靠雲來處理大量的工作負載,雲計算正急速的增長中。隨著使用量的增長,雲計算的技能應該要納入你的職能規畫中。

Serverless 無伺務器模式是雲計算的子集,它在企業用戶中也迅速流行,因為它提供了雲計算的所有優勢,同時還具有自動擴展功能,而幾乎不需要維護。

諸如 Amplify Framework 之類的工具使所有背景的開發人員都可以更輕鬆地啟動和運行雲以及無伺服器運算。接下來將要利用雲服務和 Amplify Framework 在雲中構建真實的全棧無伺服器應用程序。這將會使用 AWS 的 auth、API、storage 等服務。使用的架構將會包含 GraphQL 與 React。

掛鉤規則

在使用 Hooks 時,有一些原則需要遵守,以避免錯誤和異常的行為:

  • 掛鉤僅能在組件中運行

    掛鉤只能從 React 組件中調用。也可以將它們添加到自定義掛鉤中,然後將它們添加到組件中。 掛鉤不是常規的 JavaScript,它們是一種 React 模式,可建立以模組化方式整合到其他庫中。

  • 將功能分解為多個掛鉤是個好主意

    拆分功能為多個掛鉤,除了使代碼更易於閱讀外,還有另一個好處。由於 Hook 是按順序調用的,因此最好使它們保持較小的功能。調用後,React 會將 Hooks 的值保存在陣列中,所以比較好追蹤較小功能的運作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const Counter = () => {
    const [count, setCount] = useState(0);
    const [checked, toggle] = useState(false);

    useEffect(() => {
    ...
    }, [checked]);

    useEffect(() => {
    ...
    }, []);

    useEffect(() => {
    ...
    }, [count]);

    return ( ...)
    };

    每個 Hook 的調用和渲染順序都相同:

    [count, checked, DependencyArray, DependencyArray, DependencyArray]
  • 掛鉤只能在頂層調用

    掛鉤應在 React 函式的頂層使用。它們不能放入條件語句,循環或嵌套函式中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    const Counter = () => {
    const [count, setCount] = useState(0);

    if (count > 5) {
    const [checked, toggle] = useState(false);
    }

    useEffect(() => {
    ...
    });

    if (count > 5) {
    useEffect(() => {
    ...
    });
    }

    useEffect(() => {
    ...
    });

    return ( ... );
    };

    當我們在 if 語句中使用 useState 時,僅當 count 大於 5 時才應調用該掛鉤。拋出的陣列值,有時是 [count, checked, DependencyArray, 0, DependencyArray],有時則會是 [count, DependencyArray, 1]。陣列中的 effect 索引對 React 很重要,這是 React 保存 effect 值的方式。

    這並不是說我們不能在 React 應用程序中使用條件語句。我們只需要以不同的方式組織這些條件。我們可以在掛鉤中嵌套 if 語句,循環和其他條件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    const Counter = () => {
    const [count, setCount] = useState(0);
    const [checked, toggle] = useState(count => (count < 5) ? undefined : false);

    useEffect(() => {
    ...
    });

    useEffect(() => {
    if (count < 5) return;
    ...
    });

    useEffect(() => {
    ...
    });

    return ( ... );
    };

    第 3 行,count 小於 5 時,checked 值為 undefined。 將條件嵌套在掛鉤內部而將該掛鉤保留在頂層,但是結果是相似的。 第 9 行的 effect 執行相同的規則,如果 count 小於 5,則 return 語句將阻止 effect 繼續執行。這將使掛鉤陣列值保持一致 [count,checked,DependencyArray,DependencyArray,DependencyArray]

    與條件語句邏輯一樣,當你需要在掛鉤中嵌套異步行為。 useEffect 將函式作為第一個參數,而不是 Promise。因此,您不能將異步函式用作第一個參數:

    1
    useEffect(async () => {})

    但是,你可以在參數函式內部嵌套一個異步函數:

    1
    2
    3
    4
    5
    6
    useEffect(() => {
    const fn = async () => {
    await SomePromise();
    };
    fn();
    });

    我們創建了一個變數 fn,以處理 async/await,然後執行該函式。 您也可以使用匿名函式:

    1
    2
    3
    4
    5
    useEffect(() => {
    (async () => {
    await SomePromise();
    })();
    });

如果遵循上述的這些規則,你可以避免 React Hooks 的一些常見問題。

使用 useReducer 改進代碼

思考一下 Checkbox 複選框組件,該組件擁有簡單的狀態,複選框值只有 “checked” 與 “not checked”。 其中,checked 是狀態值,而 setChecked 是用於更改狀態的函式。 首次渲染組件時,checked 的值為 false:

useReducer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<div id="react-container" />

<script type="text/babel">
const { render } = ReactDOM;
const { useState } = React;

const Checkbox = () => {
const [checked, setChecked] = useState(false);

return (
<>
<input
type="checkbox"
value={checked}
onChange={() => setChecked(checked => !checked)}
/>
<span className="margin-left-sm">{checked ? "checked" : "not checked"}</span>
</>
);
};

const App = () => (
<>
<h1>Hello Tainan!</h1>
<Checkbox />
</>
);

render(
<App />,
document.getElementById("react-container")
);
</script>

效果不錯,但是此功能如果用在不同的的區域可能會引起一些維護上的困擾。

1
onChange={() => setChecked(checked => !checked)}

乍看下感覺還可以,這裡會有甚麼問題嗎?我們送出一個接受當前 checked 值並返回相反的!checked 的函式。 開發人員有可能會送出錯誤的函式並破壞整個過程。與其以這種方式進行處理,為什麼不提供一個功能函式呢?

讓我們添加一個名為 toggle 的函式,該函式將執行與 setChecked 相同的操作,並返回與 checked 當前值相反的值:

Checkbox and toggle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const Checkbox = () => {
const [checked, setChecked] = useState(false);

const toggle = () => setChecked(checked => !checked);

return (
<>
<input
type="checkbox"
value={checked}
onChange={toggle}
/>
<span className="margin-left-sm">{checked ? "checked" : "not checked"}</span>
</>
);
};

這個好些了。onChange 設置為可預測的值:toggle 函式。我們知道每次使用該函式時,它將執行預定地操作,產生更可預測的結果。

這裡我們在 toggle 函式中使用了setChecked 的涵式:

1
setChecked(checked => !checked);

我們現在要使用另一個方式來引用這個函式,checked =>!checked,它是一個 reducer。reducer 函式最簡單的定義是,它接受當前狀態並返回新狀態。 如果 checked 為 false,則應返回相反的 true。

在最初我們將此行為硬編碼為 onChange 事件,我們現在可以將邏輯簡化為 reducer 函式,該邏輯將始終產生相同的結果。

我們將使用 useReducer 代替組件中的 useState:

Checkbox
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Checkbox = () => {
const [checked, toggle] = useReducer(checked => !checked, false);

return (
<>
<input
type="checkbox"
value={checked}
onChange={toggle}
/>
<span className="margin-left-sm">{checked ? "checked" : "not checked"}</span>
</>
);
};

第 2 行,useReducer 接受一個 reducer 函式和初始狀態 false。 然後,將 onChange 函式設置為 toggle,這將會調用 reducer 函式。

我們較早的 reducer,checked =>!checked,就是一個很好的例子。如果將相同的輸入提供給函式,則應預期會有相同的輸出。

這個概念起源於 JavaScript 中的 Array.reduce。reduce 從根本上與 reducer 具有相同的作用:它接受一個函式(這個函式會將所有值都簡化為單一的值)和一個初始值,並返回一個單一的值。

Array.reduce 接受一個 reducer 函式和一個初始值。 對於 numbers 陣列中的每個值,將調用 reducer 直到返回一個值:

Array.reduce
1
2
3
const numbers = [28, 34, 67, 68];

numbers.reduce((number, nextNumber) => number + nextNumber, 0); // 197

發送到 Array.reduce 的 reducer 函式接受兩個參數。所以這裡我們也可以將多個參數發送給 useReducer 的 reducer 函式:

Numbers
1
2
3
4
5
6
7
8
const Numbers = () => {
const [number, setNumber] = useReducer(
(number, newNumber) => number + newNumber,
0
);

return <h1 onClick={() => setNumber(30)}>{number}</h1>
};

第 3 行就是這裡的 reducer 函式,接受了兩個參數 number 與 newNumber。在第 7 行調用 setNumber 時就會執行這個 reducer 函式,傳入 30 給 newNumber,然後加到當前的 number 狀態值。現在,每次單擊 h1 時,我們都會將總數加 30。

useReducer 處理複雜狀態

隨著狀態變得越來越複雜,useReducer 可以幫助我們更可預測地處理狀態更新。來看另外一個含有 User 數據的物件:

user data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const firstUser = {
id: "0391-3233-301",
firstName: "Scott",
lastName: "Tiger",
city: "Tainan",
email: "scott@example.com",
admin: false
};

const User = () => {
const [user, setUesr] = useState(firstUser);

return (
<div>
<h1>{user.firstName} {user.lastName} - {user.admin ? "Admin" : "User"}</h1>
<p>Email: {user.email}</p>
<p>Location: {user.city}</p>
<button>Make Admin</button>
</div>
);
}

管理狀態時常見的錯誤是覆蓋狀態:

1
2
3
4
5
6
7
8
<button
onClick={(e) => {
e.preventDefault();
setUser({ admin: true });
}}
>
Make Admin
</button>

第 4 行這樣做將覆蓋 firstUser 的狀態,並將其替換為我們發送給 setUser 函式的內容:{admin:true}。這可以透過 JavaScript 的展開運算子(Spread syntax)解決此問題:展開 user 的當前值,然後覆蓋 admin 值:

1
2
3
4
5
6
7
8
<button
onClick={(e) => {
e.preventDefault();
setUser({ ...user, admin: true });
}}
>
Make Admin
</button>

這將採用初始狀態並輸入新的鍵/值:{ admin:true }。我們需要在每個 onClick 中重寫此邏輯,這很容易出錯,也可能會忘記這樣做。現在改用 useReducer:

useReducer
1
2
3
4
5
6
7
const User = () => {
const [user, setUser] = useReducer(
(user, newDetails) => ({ ...user, ...newDetails }),
firstUser
);
...
}

然後,我們發送新的狀態值 newDetails,傳遞給 reducer,並將其推入物件。

1
2
3
4
5
6
7
8
<button
onClick={(e) => {
e.preventDefault();
setUser({ admin: true });
}}
>
Make Admin
</button>

當狀態具有多個子值或下一個狀態取決於上一個狀態時,此模式很有用。

對我來說資料庫的備份比甚麼都重要,這種觀念讓我延伸到對文字或是程式的專案備份的重要性。目前我們大部分都是使用 Oracle PL/SQL,程式碼都是儲存在資料庫中,我們不用另外備份程式碼。

但對軟體程式專案來說,尤其是團隊合作的專案,這是不夠的。備份機制應該包括版本控制或是追蹤、維護修訂版本。

SQL Developer 從第 4 版就有支援 Git 版本控制,提供一些 GUI 的介面,讓你操作更方便。以下範例我用的 SQL Developer 版本是 19.2.1。

Create Git Repository

首先選擇 Team > Versions 與 View > Files 打開左邊的 Versions 與 Files 區塊。

建立新的 Git 儲存庫 (Repository 或稱容器),Teams > Git > Initialize

選擇 Git 儲存庫所在的目錄:

建立新的 Git 儲存庫後,在 Files 區塊選擇剛建立的 Git 儲存庫目錄。然後在 Versions 區塊就可以看到 Git 儲存庫的基本資訊。

現在可以開始使用 Git 儲存庫目錄存儲檔案了。

新增、修改或刪除檔案後將這些更動後的檔案加到 Git stage,然後 commit Git 儲存庫。

commit Git 儲存庫時記得要輸入 Comments。

現在修改 Demo_emp.sql 後,commit Git 儲存庫。現在 Git 儲存庫有兩個 commit 的版本。

現在因某些原因,想將舊的版本找回來:

選擇先前 commit 的版本:

左右兩個視窗,可比較不同的版本:

選擇 Open 的版本:

Github 中央儲存庫

如果是一個團隊開發的專案,必須有一個共同存儲專案程式碼或其他資料的地方。中央儲存庫可以選擇放在公司內部的媒體上,也可以選擇雲端的 Github 中央儲存庫,以方便部署到其他雲端的服務上。

這裡有有關 Git 遠端中央儲存庫 的基本介紹。

你也可以參考 Hexo 快速入門 使用 Git 版本控制章節,有關 GitHub 中央儲存庫的介紹。

現在我要將它存儲到雲端的 Github 中央儲存庫。首先到 Github 建立一個儲存庫 (Repository):

這裡選擇 Private ,因為我們不想對外公開。不要選擇 Initialize this repository 的任何選項,因為我們在本機上已有一個 Git 專案儲存庫,我們要從本地端將 Git 儲存庫上傳到 Github。

按下 Create Repository 按鈕後,會有一些指引:

你可以到本地端 Git 儲存庫的目錄,依照上面的指引使用 Git 指令將 Git 儲存庫上傳到 Github。但我們現在要使用 SQL Developer 中的 Git。

新增一個 Remote 連結:

將 Github 專案儲存庫的遠端網址複製貼上,這裡的 Remote name 取名為: origin。

設定好遠端的連結,現在可以將本地端的儲存庫 push 上傳到 Github 中央儲存庫。

輸入你的 Github 帳號密碼:

現在你有本地端的 Git 儲存庫與 Github 中央儲存庫。在本地端開發測試完成後可以使用 push 將本地端的程式同步到 Github 中央儲存庫。其他開團隊的開發者可以使用 pull 將 Github 中央儲存庫的程式抓到他本地端的 Git 儲存庫。

因為目前的 Github 專案儲存庫是私有的 (Private),我們可以邀請其他的 Github 帳號共同開發。被邀請的帳號就可以有權限新增修改 此 Github 儲存庫。

SQL Developer 提供的 Git 介面並沒有限定這個 Git 儲存庫必須得在 SQL Developer 初始化的 Git 儲存庫才可以。只要是 Git 儲存庫都可以使用 SQL Developer 中的 Git 介面,你可以不用安裝其他的 Git GUI 軟體。

到目前為止,我們添加到依賴陣列的依賴項是字符串。字符串,布爾值,數字等 JavaScript 原生型態都是可以有比較性的。

字符串將等於預期的字符串。

1
2
3
if ( "tainan" === "tainan") {
console.log("Tainan!!");
}

但是,當我們開始比較物件,陣列和函式時,這就有不同的結果了。 例如,如果我們比較兩個陣列:

1
2
3
if ( [1, 2, 3] !== [1, 2, 3]) {
console.log("但它們是一樣的");
}

這兩組陣列不相等,即使它們的長度和項目看起來相同。 這是因為它們是外觀相似的陣列,但卻是兩個不同的實例 (instance)。如果我們創建一個變數來保存該陣列的值然後進行比較,我們將看到預期的結果:

1
2
3
4
const array = [1, 2, 3];
if ( array === array) {
console.log("因為它是同一個實例 (instance)");
}

深度檢查依賴項 (Deep Checking Dependencies)

在 JavaScript 中,陣列、物件和函式只有在它們完全相同的實例時才是相同的。 那麼,這與 useEffect 依賴陣列有何關係? 為了了解這一點,我們需要一個盡可能頻繁強制渲染的組件。我們要構建一個掛鉤,該掛鉤使組件在每次按下任何鍵時都會渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<div id="react-container"></div>

<script type="text/babel">
const { useState, useEffect } = React;
const { render } = ReactDOM;

const useAnyKeyToRender = () => {
const [, forceRender] = useState();

useEffect(() => {
window.addEventListener("keydown", forceRender);
return () => window.removeEventListener("keydown", forceRender);
}, []);
};

const App = () => {
useAnyKeyToRender();

useEffect(() => {
console.log("fresh render");
});

return <h1>Open the console</h1>;
};

render(
<App />,
document.getElementById("react-container")
);
</script>
  • 第 7 行 useAnyKeyToRender 是我們自定義的掛鉤。
  • 第 8 行 我們要強制渲染的工作就是調用狀態更改函式。我們不在乎狀態值。我們只需要狀態函式:forceRender。
  • 第 11 行 在第一次渲染組件時,我們將監聽 keydown 事件。按下某個鍵時,我們將通過調用 forceRender 強制組件進行渲染。

通過將此掛鉤添加到組件,我們只需按一下任何鍵就可以強制其重新渲染。我們將此掛鉤直接加入 App 組件。現在,每次我們按下一個鍵,都會渲染 App 組件。每次渲染 App 組件時都會調用 useEffect 將 “fresh render” 記錄到控制台。

現在讓我們在 App 組件中調整 useEffect 來引用 word 值。 如果 word 值更改,我們將重新渲染:

1
2
3
4
const word = "tainan";
useEffect(() => {
console.log("fresh render");
}, [word]);

現在不會在每個 keydown 事件上調用 useEffect,我們只會在首次渲染之後以及 word 值更改的時後會調用此方法。word 的值沒有改變,因此不會發生後續的重新渲染。 將原生類型或數字添加到依賴陣列,會按預期方式工作。 該 effect 只被調用一次。

如果我們改用字串陣列,會發生什麼?

1
2
3
4
const words = ["hello", "tainan", "!"];
useEffect(() => {
console.log("fresh render");
}, [words]);

變數 words 是一個陣列,因為每次渲染都會宣告一個新陣列,是一個新實例,JavaScript 認定 words 已被更改,因此每次都會觸發重新渲染調用 “fresh render” effect。

將變數 words 宣告在 App 組件之外可以解決該問題:

1
2
3
4
5
6
7
8
9
10
11
const words = ["hello", "tainan", "!"];

const App = () => {
useAnyKeyToRender();

useEffect(() => {
console.log("fresh render");
}, [words]);

return <h1>Open the console</h1>;
};

在這種情況下,依賴陣列引用在函式外部宣告的 words 實例,在第一個渲染之後不會再次調用 “fresh render” effect,因為 words 與上次渲染時是同一個實例。對於此示例來說,這是一個很好的解決方案,但並非總是可能 (或建議) 在函式範圍之外定義變數。有時,傳遞給依賴陣列的值需要宣告在函式範圍內。

例如,我們可能需要從類似 children 的 React 屬性建立 words 陣列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const WordCount = ({ children = "" }) => {
useAnyKeyToRender();

const words = children.split(" " );

useEffect(() => {
console.log("fresh render");
}, [words]);

return (
<>
<p>{children}</p>
<p>
<strong>{words.length} - words</strong>
</p>
</>
);
};

const App = () => {
return <WordCount>You are not going to believe this but...</WordCount>
};

App 組件包含一些字串,它們是 WordCount 組件的子代,WordCount 組件將 children 作為屬性。 然後,我們在組件中將這些單字使用 .split 方法轉換為單字陣列。我們希望該組件僅在單字更改時才會重新渲染。但是事與願違,一旦按下任何鍵,我們就會在控制台中看到 “fresh render”。

React 提供了一種避免這些額外渲染的方法。此問題的解決方案是另一個掛鉤:useMemo。

useMemo 調用一個函式來計算已記憶的值 (memoized value)。在計算機科學中,記憶 (memoization) 是一種用於提高性能的技術。在記憶功能中,函式調用的結果將會保存並緩存。 然後,當使用相同的輸入再次調用該函式時,將返回緩存的值。在 React 中,useMemo 允許我們將緩存的值與其自身進行比較,以查看其是否實際被更改。

首先,讓我們導入 useMemo 掛鉤。

useMemo
1
const { useState, useEffect, useMemo } = React;

然後,我們將傳遞給一個用於計算和創建記憶值的函式給 useMemo 掛鉤,這個函式返回的值將被用來設定 words:

1
2
3
4
5
6
7
8
const words = children.split(" ");



const words = useMemo(() => {
const words = children.split(" ");
return words;
}, []);

與 useEffect 一樣,useMemo 也有依賴陣列:

useMemo
1
const words = useMemo(() => children.split(" "));

當我們在 useMemo 中不包括依賴陣列時,將會在每次渲染時重新計算 words。依賴陣列控制何時應調用回調函式。這裡將 children 值加到依賴陣列:

useMemo
1
const words = useMemo(() => children.split(" "), [children]);

words 陣列取決於 children 屬性,如果子元素發生變化,我們應該為反映該變化的 words 計算一個新值。現在,useMemo 將在組件第一次渲染時以及 children 屬性更改時為 words 計算一個新值。

在創建 React 應用程序時,需要好好理解 useMemo 掛鉤的用途。

useCallback 的使用方式類似 useMemo,但它用於記億函式而不是值。

useCallback
1
2
3
4
5
6
7
8
9
const fn = () => {
console.log("Hello");
console.log("Tainan");
};

useEffect(() => {
console.log("fresh render");
fn();
}, [fn]);

fn 是一個函式。它是 useEffect 的依賴項,但是就像 words 一樣,JavaScript 假定 fn 每次渲染都是不同的實例。因此,它會在每次按鍵渲染時觸發 effect。 這不是很理想的,這可以使用 useCallback 來包裝函式:

useCallback
1
2
3
4
const fn = useCallback(() => {
console.log("Hello");
console.log("Tainan");
}, []);

useCallback 會記住 fn 的函數值。就像 useMemo 和 useEffect 一樣,它也有依賴陣列作為第二個參數。在這裡,由於依賴陣列是空陣列,因此只會創建了一次回調函式。

其實我們可以使用 useMemo 達到一樣的結果:

useMemo
1
2
3
4
5
6
7
8
const fn = useMemo(() => {
const fn = () => {
console.log("Hello");
console.log("Tainan");
};

return fn;
}, []);

useCallback 只省掉幾個程式碼字元而已。

When to useLayoutEffect

我們知道,渲染始終在 useEffect 之前。 渲染首先發生,然後所有的 effect 按順序運行,並且可以完全訪問渲染中的所有值。

在 React 還有另一種 effect 掛鉤:useLayoutEffect。

useLayoutEffect 可以在渲染週期的特定時刻被調用。這一系列的週期事件如下:

  1. Render
  2. 調用 useLayoutEffect
  3. 瀏覽器繪製畫面:組件的元素實際添加到 DOM 的時間
  4. 調用 useEffect

我們可以通過添加一些簡單的控制台訊息來觀察這些事件:

render cycle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div id="react-container"></div>

<script type="text/babel">
const { useEffect, useLayoutEffect } = React;
const { render } = ReactDOM;

const App = () => {
useEffect(() => console.log("useEffect"));
useLayoutEffect(() => console.log("useLayoutEffect"));
return <div>ready</div>
};

render (
<App />,
document.getElementById("react-container")
);
</script>

在 App 組件中,useEffect 是第一個掛鉤,其後是 useLayoutEffect,但我們看到 useLayoutEffect 在 useEffect 之前被調用。

useLayoutEffect
useEffect

useLayoutEffect 在渲染之後但在瀏覽器繪製更改之前被調用。在大多數情況下,useEffect 是完成工作的正確工具,但是如果您的 effect 對於瀏覽器繪畫(螢幕上 UI 元素的外觀或位置)至關重要,則可能要使用 useLayoutEffect。

例如,您可能需要在調整窗口大小時獲取元素的寬度和高度。

useWindowSize
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const useWindowSize = () => {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);

const resize = () => {
setWidth(window.innerWidth);
setHeight(window.innerHeight);
};

useLayoutEffect(() => {
window.addEventListener("resize", resize);
resize();
return () => window.removeEventListener("resize", resize);
}, []);

return [width, height]
};

窗口的寬度和高度是我們的組件在瀏覽器繪製之前可能需要的信息。useLayoutEffect 用於在繪製之前計算窗口的寬度和高度。

另一個使用 useLayoutEffect 的示例是在追踪鼠標的位置時:

useMousePosition
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const useMousePosition = () => {
const [x, setX] = useState(0);
const [y, setY] = useState(0);

const setPosition = ({ x, y }) => {
setX(x);
setY(y);
};

useLayoutEffect(() => {
window.addEventListener("mousemove", setPosition);
return () => window.removeEventListener("mousemove", setPosition);
}, []);

return [x, y];
};

繪製螢幕時很有可能會使用鼠標的 x 和 y 位置。 useLayoutEffect 可用於幫助在繪製之前準確計算這些位置。

了解了 React useState 與 useEffect 掛鉤,現在來實作一個可以實際應用的範例。

我們要使用先前建立的 WebSocket Service and RabbitMQ 即時訊息通知來建立一個 Web App 即時訊息通知功能。透過這個 WebScocket 伺服器可以讓你從任何地方送出訊息,並即時顯示在你的 APEX Web Application 上。

加入基本的原始碼

建立一個新的 APEX Page,在 Page Properties 的 JavaScript File URLs 加入三個原始碼。

File URLs
1
2
3
https://unpkg.com/react@16.13.1/umd/react.development.js
https://unpkg.com/react-dom@16.13.1/umd/react-dom.development.js
https://unpkg.com/@babel/standalone/babel.min.js

應用程序模組

然後在 Page Properties 的 JavaScript Function and Global Variable Declaration 加入我們自己的程式碼。

Demo module
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
var Demo = (() => {
const uuid = () => {
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4()
};

const toLoalISOString = (date) => {
var tzo = -date.getTimezoneOffset(),
dif = tzo >= 0 ? '+' : '-',
pad = function (num) {
var norm = Math.floor(Math.abs(num));
return (norm < 10 ? '0' : '') + norm;
};
return date.getFullYear() +
'-' + pad(date.getMonth() + 1) +
'-' + pad(date.getDate()) +
'T' + pad(date.getHours()) +
':' + pad(date.getMinutes()) +
':' + pad(date.getSeconds()) +
dif + pad(tzo / 60) +
':' + pad(tzo % 60);
};

const roles = {
"roleDemo": {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3c3NpZCI6IjAwMDEzY2Y1LTcxOWItNDAxZC05YWUyLWRjZjJkYTVlZTRmYyIsIk5hbWUiOiJyb2xlRGVtbyIsImlhdCI6MTU5NjQ0MDYxNH0.KYIuu2xqITmStn1Yy1cu2xuGOcd1CuXYKpLGAU9OmhI',
wssid: '00013cf5-719b-401d-9ae2-dcf2da5ee4fc'
},
"roleOne": {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3c3NpZCI6ImY0YTk0MDkxLTgxYWItNDRiOC1hZWNlLTYzNTI2ZWU4OGFlNiIsIk5hbWUiOiJyb2xlT25lIiwiaWF0IjoxNTk2NDQwNzA3fQ.IFdERyYpwAs3zLx9eUznqLdbuq1xjLF3snw3etgbTpQ',
wssid: 'f4a94091-81ab-44b8-aece-63526ee88ae6'
},
"roleOther": {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3c3NpZCI6ImRlYjg4N2ZhLTNlODQtNGEyYi1hNzZmLTA2OWY4MzU0ZmE0MyIsIk5hbWUiOiJyb2xlT3RoZXIiLCJpYXQiOjE1OTY0NDA3ODd9.jSnLlUeYfCpCR7_bg5nlPeQGKwFtI8IEa-NPsWfgtIw',
wssid: 'deb887fa-3e84-4a2b-a76f-069f8354fa43'
}
};

const dataSample = {
id: uuid(),
title: 'Title 標頭',
message: 'Hello, Tainan. 自己從瀏覽器送出的訊息。'
};

const socket = (roleName = "roleDemo") => {
const { token } = roles[roleName];
const url = `ws://10.11.25.138:4040/?token=${token}`;
const ws = new WebSocket(`ws://10.11.25.138:4040/?token=${token}`);

const subscribe = (callback) => {
ws.onmessage = ({ data }) => {
try {
const parsedData = JSON.parse(data);
callback(parsedData);
} catch(err) {
console.log(err);
callback(null);
}
};
};

const sendTo = (roleName = "roleDemo", data = dataSample) => {
const { wssid } = roles[roleName];
const message = { wssid, ...data, created: Date.now() };

return ws.send(JSON.stringify(message));
};

return { subscribe, sendTo };
};

return { uuid, toLoalISOString, socket, ...Demo };
})(Demo || {});

這裡列出 3 個可用的測試角色,你可以申請自己的 token 與 wssid。這個範例預設會使用 roleDemo 訂閱 WebSocket Service。sendTo 函式會用來測試送出訊息,預設也是會送給 roleDemo 自己。也用一個預設的訊息範例 dataSample。這些訊息的資料欄位請視你的所需自行調整。

現在可以測試一下我們的模組。開啟瀏覽器的 Console:

1
2
3
4
5
6
7
const wss = Demo.socket();

wss.subscribe(console.log);

wss.sendTo();

{"wssid":"00013cf5-719b-401d-9ae2-dcf2da5ee4fc","id":"e5adf55e-1949-45c7-8cd5-d4c47ae1bb28","title":"Title 標頭","message":"Hello, Tainan. 自己從瀏覽器送出的訊息。","created":1602638703748}

React 組件

建立一個新的 Region,我們現在可以在 Region Properties Source 的 Text 中加 React 程式碼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<div id="react-container"></div>

<script type="text/babel">
const { useState, useEffect } = React;
const { render } = ReactDOM;
const { toLoalISOString, socket } = Demo;
const wss = socket("roleDemo");

const useWssMessages = () => {
const [messages, setMessages] = useState([]);
const addMessage = message => setMessages(allMessages => (message ? [message, ...allMessages] : allMessages));

useEffect(() => {
wss.subscribe(addMessage);
}, []);

return messages;
};

const Message = ({title, message, created}) => {
return (
<p>Title: {title} Content: {message} AT: {toLoalISOString(new Date(created))}</p>
);
};

const MessageList = () => {
const messages = useWssMessages();
if (!messages.length) return <div>No Messages Listed.</div>

return (
<>
<p>Messages: {messages.length}</p>
{
messages.map((message, i) => <Message key={i} {...message} />)
}
</>
);
};

const App = () => (
<>
<h3>WebSocket instant messages</h3>
<MessageList />
</>
);

render(
<App />,
document.getElementById("react-container")
);
</script>

開啟瀏覽器的 Console:

1
wss.sendTo();

你的 APEX Page 畫面應該馬上會顯示訊息。

使用 GraphQL API 送出訊息

透過 GraphQL API ,其實你可以從任何的地方使用 http 協定送出即時訊息。

可以使用 GraphQL PlayGround 送出測試資料。

GrlphQL API
1
2
3
4
5
6
7
8
9
10
11
// url: GraphQL http://10.11.25.138:4000/v1/graphql

// Mutation
mutation sendToQueue($queue: String!, $message: String!) {
sendToQueue(queue: $queue, message: $message)
}

// Query Variables
{"queue":"wss.notification.demo",
"message":"{\"wssid\":\"00013cf5-719b-401d-9ae2-dcf2da5ee4fc\",\"title\":\"Hello Tainan! 台南!\",\"message\":\"GraphQL http://10.11.25.138:4000/v1/graphql API\",\"created\":1602641256154}"
}

現在從 Oracle 資料庫透過 GraphQL API 送出訊息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
declare
l_http_request Utl_Http.req;
l_http_response Utl_Http.resp;
l_url varchar2(255) := 'http://10.11.25.138:4000/v1/graphql';
l_buffer varchar2(32767);
l_item varchar2(1024);
l_queue varchar2(32) := 'wss.notification.demo';
l_wssid varchar2(36) := '00013cf5-719b-401d-9ae2-dcf2da5ee4fc';
l_query varchar2(32767);

procedure send(empno_in in number)
is
rec emp%rowtype;
l_message varchar2(1024);
begin
select * into rec
from emp
where empno = empno_in;

l_message := '"{'||
'\"wssid\":' ||'\"'||l_wssid||'\",'||
'\"id\":' ||'\"'||rec.empno||'-'||rec.deptno||'-'||rec.job||'\",'||
'\"title\":' ||rec.empno||','||
'\"message\":' ||'\"'||rec.ename||'\",'||
'\"created\":'||'\"'||to_char(sysdate,'yyyy-mm-dd"T"hh24:mi:ss')||'\"'||
'}"';

l_query := '{"query":"mutation sendToQueue($queue: String!, $message: String!)' ||
' {\n sendToQueue(queue: $queue, message: $message)\n}",' ||
'"variables":{"queue":"' || l_queue || '","message":' || l_message ||'}}';

--dbms_output.put_line(l_query);

l_http_request := utl_http.begin_request(l_url, 'POST','HTTP/1.1');
utl_http.set_header(l_http_request, 'user-agent', 'mozilla/4.0');
utl_http.set_header(l_http_request, 'content-type', 'application/json; charset=utf-8');
utl_http.set_header(l_http_request, 'Content-Length', lengthb(l_query));
utl_http.set_body_charset(l_http_request, charset => 'utf-8');
utl_http.write_text(l_http_request, l_query);
l_http_response := utl_http.get_response(l_http_request);

-- process the response from the HTTP call
begin
loop
utl_http.read_line(l_http_response, l_buffer);
dbms_output.put_line(l_buffer);
end loop;
utl_http.end_response(l_http_response);
exception
when utl_http.end_of_body
then
utl_http.end_response(l_http_response);
end;
exception
when no_data_found
then
dbms_output.put_line('No data found.');
end;
-- main body
begin
for rec in (select rownum, empno from emp where empno = 7654)
loop
send(rec.empno);
end loop;
end;
/

接收與送出所需要的資料欄位請視所需自行調整。

你每次收到的訊息如果有可能都有不同的資料欄位,可修改 Message 組件:

Message
1
2
3
4
5
6
7
8
9
const Message = ( props ) => {
return (
<p>
{Object.keys(props).map((prop, i) =>
(<span key={i}> { prop.toUpperCase() }: { props[prop] } </span>)
)}
</p>
);
};

渲染 (rendering) 是 React 應用程序的心臟,當某些東西(屬性,狀態)發生變化時,組件樹會重新渲染,將最新數據反映給使用者界面。到目前為止,useState 一直是描述組件渲染方式的主要工作。但是我們可以做的更多。還有更多的 Hooks 可以定義有關為什麼以及何時進行渲染的規則。還有更多的掛鉤可以增強渲染效能。

之前,我們介紹了 useState,useContext,我們看到可以將這些 Hooks 組合到我們自己的自定義 Hooks 中:useInput 和 useColors。 不過,還有更多,React 附帶了更多可用的 Hooks。再來,我們將仔細研究 useEffect,useLayoutEffect 和 useReducer。所有這些對於構建應用程序都至關重要。我們還將介紹 useCallback 和 useMemo,它們可以幫助優化我們的組件以提高性能。

使用 useEffect

現在,我們對渲染組件時發生的情況有了很好的了解。組件僅僅是渲染用戶界面的函式。渲染在應用程序首次加載以及屬性和狀態(props and state)值更改時發生。但是,有時候我們希望在 React 渲染更新 DOM 之後執行一些額外的程式碼。網路請求、手動變更 DOM、和 logging,該如何做?

我們使用一個簡單的 Checkbox 複選框組件來觀察組件的渲染週期。我們使用 useState 設置一個 checked 值,並使用 setChecked 函式來更改 checked 的值。用戶可以選擇和取消選擇該複選框,但是我們如何提醒用戶該複選框已被選擇呢?

我們使用 console.log 來觀察一下組件的渲染週期:

Checkbox
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<div id="react-container"></div>

<script type="text/babel">
const { useState } = React;
const { render } = ReactDOM;

const Checkbox = () => {
const [checked, setChecked] = useState(false);

console.log(`before render: checked: ${checked.toString()}`);

return (
<>
<input
type="checkbox"
value={checked}
onChange={() => setChecked(checked => !checked)}
/>
<span className="margin-left-sm">{checked ? "checked" : "not checked"}</span>
{console.log(`rendering: checked: ${checked.toString()}`)}
</>
);

console.log(`after render: checked: ${checked.toString()}`);
};

const App = () => (
<>
<h1>Hello Tainan!</h1>
<Checkbox />
</>
);

render (
<App />,
document.getElementById("react-container")
)

</script>

組件的渲染主要是第 12 行到第 22 行 return 的 JSX 程式碼。我們在第 10 行渲染之前、第 20 行渲染時與第 24 行渲染後添加了 console.log。執行的結果是:

1
2
before render: checked: false
rendering: checked: false

第 24 行渲染後調用的 console.log 沒有被執行,因為它位於 return 之後,將永遠無法到達該代碼。

為確保渲染後按預期方式看到 console.log,我們可以使用 useEffect。放置在 useEffect 函式內的 console.log 意味著該函式將在渲染後被當成一種副作用(side effect)而被調用。

Checkbox
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const { useState, useEffect } = React;
...
const Checkbox = () => {
const [checked, setChecked] = useState(false);

console.log(`before render: checked: ${checked.toString()}`);

useEffect(() => {
console.log(`after render: checked: ${checked.toString()}`);
});

return (
<>
<input
type="checkbox"
value={checked}
onChange={() => setChecked(checked => !checked)}
/>
<span className="margin-left-sm">{checked ? "checked" : "not checked"}</span>
{console.log(`rendering: checked: ${checked.toString()}`)}
</>
);
};

第 1 行加入 useEffect;第 9 行將原來在渲染後調用的 console.log 放置在 useEffect 函式裡,而 useEffect 函式將在渲染後作為副作用而被調用。

1
2
3
before render: checked: false
rendering: checked: false
after render: checked: false

當渲染需要引起副作用時,我們使用 useEffect。 可以將副作用視為函式除了使用 return 所返回的結果之外還可以完成並影響函式外的事情。 Checkbox 函式使用 return 渲染 UI,但是我們希望 Checkbox 組件做更多的事情。組件執行除 return UI 之外的其他操作稱為 effect

console.log 或與瀏覽器或本機 API 的互動都不是渲染的一部分,這不是 return 的一部分。在 React 應用程序中,渲染會影響這些事件的結果,我們可以使用 useEffect 等待渲染之後,再將值提供給 console.log:

useEffect
1
2
3
useEffect(() => {
console.log(checked ? "Yes, checked" : "No, not checked");
});

同樣,我們可以使用渲染時的 checked 值,然後將其存儲到 localStorage 中。

useEffect
1
2
3
useEffect(() => {
localStorage.setItem("checkbox-value", checked);
});

將 useEffect 視為渲染後發生的函式。當渲染觸發時,我們可以訪問組件中當前的狀態值,並使用它們執行其他操作,這些操作都必須在渲染後調用,否則就與渲染的 UI 不相等了。然後,一旦我們再次渲染,整個過程就會重新開始。 新值,新渲染,新 effects。

依賴陣列 (The Dependency Array)

useEffect 的設計旨在與其他有狀態的 Hooks,例如 useState 和迄今未提及的 useReducer 結合使用。當狀態改變時,React 將重新渲染組件樹。然後,在這些渲染之後調用 useEffect。

我們來看看與 useState 結合使用的範例。

useState and useEffect
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const { useState, useEffect } = React;
const { render } = ReactDOM;

const App = () => {
const [val, set] = useState("");
const [phrase, setPhrase] = useState("example phrase");

const createPhrase = (e) => {
e.preventDefault();

setPhrase(val);
set("");
};

useEffect(() => {
console.log(`typing "${val}"`);
});

useEffect(() => {
console.log(`saved phrase: "${phrase}"`);
});

return (
<>
<label className="margin-right-sm">Favorite phrase:</label>
<input
value={val}
placeholder={phrase}
onChange={e => set(e.target.value)}
/>
<button
className="t-Button t-Button--hot margin-left-sm"
onClick={createPhrase}
>Send</button>
</>
);
};

render (
<App />,
document.getElementById("react-container")
);

val 是一個狀態變數,表示輸入字段的值。每次輸入字段的值時 val 都會更改,這會使組件在用戶每次鍵入新字符時渲染 App 組件。當用戶單擊 “Send” 按鈕時,文本區域的 val 會被保存到 phrase 狀態變數,並且重置 val 為空字串,這將清空文本字段。這是一個不斷渲染的過程。

這可以按預期工作,但是 useEffect 掛鉤被調用的次數超過了應有的次數。 每次鍵入新字符渲染後,兩個 useEffect Hooks 均會被調用:

console.log
1
2
3
4
5
6
7
8
9
10
11
12
typing ""                             // First Render
saved phrase: "example phrase" // First Render
typing "H" // Second Render
saved phrase: "example phrase" // Second Render
typing "He" // Third Render
saved phrase: "example phrase" // Third Render
typing "Hel" // Fourth Render
saved phrase: "example phrase" // Fourth Render
typing "Hell" // Fifth Render
saved phrase: "example phrase" // Fifth Render
typing "Hello" // Sixth Render
saved phrase: "example phrase" // Sixth Render

我們不希望在每個渲染調用所有的 effect。 我們需要將 useEffect 掛鉤與特定的數據更改相關聯,當某些數據更改時才調用 useEffect。為了解決這個問題,我們可以合併依賴陣列(Dependency array)。依賴陣列可用於控制何時調用 useEffect:

Dependency array
1
2
3
4
5
6
7
useEffect(() => {
console.log(`typing "${val}"`);
}, [val]);

useEffect(() => {
console.log(`saved phrase: "${phrase}"`);
}, [phrase]);

第 3 與第 7 行我們將依賴陣列添加到兩個 effect 中,以控制它們何時被調用。現在,僅在依賴陣列中的值更改時才會調用這些 effect。

1
2
3
4
5
6
7
8
9
typing ""                             // First Render
saved phrase: "example phrase" // First Render
typing "H" // Second Render
typing "He" // Third Render
typing "Hel" // Fourth Render
typing "Hell" // Fifth Render
typing "Hello" // Sixth Render
typing "" // Seventh Render
saved phrase: "Hello" // Seventh Render

依賴陣列畢竟是一個陣列,因此可以檢查依賴陣列中的多個值。假設我們想在 val 或 phreae 發生變化時調用 useEffect。

Dependency array
1
2
3
useEffect(() => {
console.log("either val or phrase has changed");
}, [val, phrase]);

還可以提供一個空陣列,空的依賴陣列會導致 effect 在初始渲染之後僅被調用一次

Dependency array
1
2
3
useEffect(() => {
console.log("only once after initial render");
}, []);

依賴陣列中沒有依賴項意味著沒有更改,因此 effect 將不再被調用。 僅在第一個渲染上調用的 effects 對於初始化非常有用:

Dependency array
1
2
3
useEffect(() => {
welcome();
}, []);

如果從 effect 中返回一個函式,則從組件樹中刪除該組件時將會調用該函式:

Dependency array
1
2
3
4
useEffect(() => {
welcome();
return () => goodbye();
}, []);

這意味著,您可以使用 useEffect 進行初始設定和卸載。空陣列意味著 welcome 將在第一次渲染時只被調用一次。然後,當組件從組件樹中刪除時,我們將返回一個 goodbye 函式作為清理函式。

此模式在許多情況下很有用。我們可以在第一次渲染時訂閱 WebSocket 服務。 然後,我們將使用清理函式退訂 WebSocket 服務。

useEffect
1
2
3
4
5
6
7
8
9
10
11
const [messages, setMessages] = useState([]);
const addMessage = message => setMessages(allMessages => [message, ...allMessages]);

useEffect(() => {
wsService.subscribe(addMessage);
welcome();
return () => {
wsService.unsubscribe();
goodbye();
};
}, []);

在一個 useEffect 進行太多工作,看起來很雜亂,也較容易出錯。將功能分為多個 useEffect 調用,通常是一個比較好的主意。

useEffect
1
2
3
4
5
6
7
8
9
useEffect(() => {
wsService.subscribe(addMessage);
return () => wsService.unsubscribe();
}, []);

useEffect(() => {
welcome();
return () => goodbye();
}, []);

我們還可以進一步增強它。這是自定義掛鉤 (custom hook) 的好地方。

useWsMessages custom hook
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const useWsMessages = () => {
const [messages, setMessages] = useState([]);
const addMessage = message => setMessages(allMessages => [message, ...allMessages]);

useEffect(() => {
wsService.subscribe(addMessage);
return () => wsService.unsubscribe();
}, []);

useEffect(() => {
welcome();
return () => goodbye();
}, []);

return messages;
};

我們的自定義掛鉤包含處理數據的所有功能,這意味著,我們可以輕鬆地與其他的組件共享此功能。在一個名為 MessageList 的新組件中,我們將可以使用自定義掛鉤:

MessageList
1
2
3
4
5
6
7
8
9
10
11
12
const MessageList = () => {
const messages = useWsMessages();

return (
<>
<h1>{messages.length}</h1>
{messages.map(message => (
<Message key={message.id} {...message} />
))}
</>
);
};

我們可以輕鬆的使用 useWsMessages 自定義掛鉤,當有訂閱的數據到達時,MessageList 組件將會重新渲染。

執行上下文(Execution Context)的概念(簡稱為 context)在 JavaScript 中非常重要。變數或函式的執行上下文定義了它可以存取運作的資料,以及它應該如何運作。 每個執行上下文都有一個關聯的 variable 物件,在這個執行上下文所定義的變數和函數,都存在這個 variable 物件上。此 variable 物件無法由程式碼作存取,一卻都在幕後運作。

在各種 JavaScript 運作的架構,都將此 context 概念運用在其結構上。GraphQL 有 context,React 也不例外。

在 React 中,context (上下文) 就像為數據設置噴射機一樣。 您可以通過創建 context provider (上下文提供者) 將數據放置在 React context 中。Context provider 是一個 React 組件,您可以將其包裹在整個組件樹或組件樹的特定部分中。

Context provider 是您的數據所在的出發機場。它也是航空公司的樞紐。所有航班都從該機場出發,飛往不同的目的地。每個目的地都是 context consumer (上下文使用者)。

context consumer 是一種從 context 檢索數據的 React 組件。這是您的數據著陸,下機並開始工作的目的地機場。

使用 context 將使我們能夠將數據存儲在單個位置,但是不需要我們將數據通過不需要它們的一堆組件傳遞。

將資料放置在 Context

為了在 React 中使用 context,我們必須首先在 context provider 中放置一些數據,然後將該 provider 添加到我們的組件樹中。React 有一個稱為 createContext 的函式,我們可以使用它創建一個新的 context 物件。 該物件包含兩個組件:context ProviderConsumer

讓我們將顏色數據 colors 放入 context 中。 通常都會將 context 添加到應用程序的入口點,然後就可以在每個組件中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<div id="react-container"></div>

<script type="text/babel">
const { createContext } = React;
const { render } = ReactDOM;
const { uuid, colorData } = Demo;

const ColorContext = createContext();

...

render(
<ColorContext.Provider value={{ colors: colorData }}>
<App />
</ColorContext.Provider>,
document.getElementById("react-container")
);
</script>

第 8 行我們使用 createContext 創建了一個新的 React context 實例,我們將其命名為 ColorContext。color context 包含兩個組件:ColorContext.Provider 和 ColorContext.Consumer。

我們需要使用 provider 將 colors 數據置於狀態中。第 13 行通過設置 Provider 的 value 屬性,可以將數據添加到 context 中。 這裡,我們向 context 添加了一個包含 colorData 數據的 colors 屬性的物件。由於我們使用 provider 包裝了整個 App 組件,因此 colors 數據將可供我們整個組件樹中含有 context consumer 的組件使用。當組件想從 context 中獲取 colors 數據時,只需要訪問 ColorContext.Consumer。

Context provider 並不必總是需要包裝整個應用程序;可以只包裝特定部分的組件,這可以使您的應用程序有更好的效能。Provider 將僅向其子組件提供 context 值。因此可以在一個應用程序使用多個 context provider。

現在我們在 conext 中提供 colors 數據,App 組件不再需要保持狀態並將其作為 props 屬性傳遞給其子組件。我們可以直接飛過 App 組件。Provider 是 App 組件的父級,它在 context 中提供 colors。 ColorList 是 App 組件的子級,它可以直接自己獲取 colors。 因此 App 組件根本不需要接觸 colors,因為 App 組件本身與 colors 無關,這種保持狀態的責任已從組件樹中解脫了。

我們可以從 App 組件中刪除很多代碼。它只需要呈現 AddColorForm 和 ColorList。 它不再需要擔心數據狀態:

App
1
2
3
4
5
6
7
8
const App = () => {   
return (
<>
<AddColorForm />
<ColorList />
</>
);
};

使用 useContext 取得資料

React 16.8 之後添加的 Hooks 使處理 context 變得更簡易。現在使用 useContext 掛鉤,直接在掛鉤中訪問 Consumer,我們不再需要直接使用 Consumer 組件。在使用 Hooks 之前,我們必須使用 context consumer 中稱為 render props 的模式從 context 中獲取 colors。Render props 作為參數傳遞給子組件。 現在我們直接使用 useContext 掛鉤從 context consumer 中獲取我們需要的那些值。

ColorList 組件不再需要從其屬性獲取 colors。它可以通過 useContext 掛鉤直接訪問它們:

ColorList
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { createContext, useContext } = React;

...

const ColorList = () => {
const { colors } = useContext(ColorContext);

if(!colors.length) return <div>No Colors Listed.</div>;

return (
<div>
{
colors.map(color => <Color key={color.id} {...color} />)
}
</div>
);
};

第 1 行將 useContext 加進來。第 6 行使用 useContext 掛鉤從 context consumer 中獲取我們需要的數據 colors。現在,ColorList 可以基於 context 中提供的數據構造用戶界面。

Stateful Context Provider

Context provider 可以將一個物件放入 context 中,但是不能自行更改 context 中的值。它需要父級組件的幫助。訣竅是創建一個能夠渲染 context provider 的有狀態父組件。當有狀態組件的狀態更改時,它將使用新的 context 數據重新渲染 context provider (context provider 也是一個 React 組件)。 而 context provider 的任何子代也將重新以新的 context 數據渲染介面。

這個會渲染 context provider 的有狀態組件是我們的 custom provider。 這個自定義的有狀態組件將我們的 App 組件與 provider 包裝在一起。

ColorProvider
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const { createContext, useContext, useState } = React;

...

const ColorProvider = ({ children }) => {
const [colors, setColors] = useState(colorData);
return (
<ColorContext.Provider value={{ colors, setColors }}>
{children}
</ColorContext.Provider>
)
};

render(
<ColorProvider>
<App />
</ColorProvider>,
document.getElementById("react-container")
);

在此 ColorProvider 組件中,我們使用 useState 掛鉤創建了 colors 狀態變數。 ColorProvider 使用 ColorContext.Provider 的 value 屬性將 colors 從狀態添加到 context。 ColorProvider 中的所有子組件都會因狀態的改變而重新渲染,而且也都包裝在 ColorContext.Provider 中,所以也都將可以從 context 直接訪問 colors 數據。

您可能已經注意到,我們在第 8 行也將 setColors 函式添加到 context 中。這使 context consumers 可以更改 colors 數據。每當調用 setColors 時,colors 數據都會更改。這將導致 ColorProvider 的渲染,我們的 UI 將被更新,以顯示新的 colors 數據。

將 setColors 添加到 context 可能不是最好的主意。它可能會讓其他開發人員和您在以後使用它時出錯。更改 colors 數據的值時只有三個選項:添加顏色,刪除顏色或對顏色進行評級。 最好在 context 中為每個操作各自添加函式功能。這樣,您就不必向 consumers 公開 setColors 函式。 您將只公開允許其進行更改的函式功能:

ColorProvider
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const ColorProvider = ({ children }) => {
const [colors, setColors] = useState(colorData);

const addColor = (title, color) =>
setColors([
...colors,
{
id: uuid(),
rating: 0,
title,
color
}
]);

const rateColor = (id, rating) =>
setColors(colors.map(color => (color.id === id ? { ...color, rating } : color ))
);

const removeColor = id => setColors(colors.filter(color => color.id !== id ));

return (
<ColorContext.Provider value={{ colors, addColor, removeColor, rateColor }}>
{children}
</ColorContext.Provider>
)
};

看起來好多了。在此我們為 context 添加了可以在 colors 陣列上進行的所有操作函式,addColor、reteColor 與 removeColor。 現在,組件樹中的任何組件都可以使用這些函式更改 colors 數據。

Custom Hooks with Context

我們還可以做出一些改變。Hooks 的引入使得 context 完全不必暴露於 consumer 組件。 但對於團隊成員而言,context 可能會造成混亂。 通過將 context 包裝在自定義掛鉤(custom hooks)中,我們可以使它們更容易理解與維護。

無需公開 ColorContext 實例,我們可以創建一個名為 useColors 的 hook ,該 hook 會從 context 返回 colors 數據。

useColors
1
2
const ColorContext = createContext();
const useColors = () => useContext(ColorContext);

這一簡單更改對應用程序結構產生了巨大影響。我們可以將呈現和使用有狀態數據 colors 所需的所有功能包裝在一個自定義掛鉤中。

現在 consumer 使用 ColorProvider 和 useColors 掛鉤是一件比較愉快的事情。App 組件的所有子級組件都可以從 useColors 掛鉤中獲取 colors 數據。 ColorList 組件現在可以直接使用自行定義的 useColors 掛鉤取得 colors 數據:

ColorList
1
2
3
4
5
6
7
const ColorList = () => {
const { colors } = useColors();

...

return ( ... );
};

我們從該組件中刪除了對 context 的任何引用。 現在,我們需要的一切都可以從我們自定義的掛鉤中獲得。

Color 組件現在也可以使用我們自行定義的 useColors 掛鉤直接獲取用於對 color 進行評級和刪除顏色的函式 rateColor 與 removeColor:

Color
1
2
3
4
5
6
7
8
9
10
11
12
13
const Color = ({ id, title, color, rating }) => {
const { rateColor, removeColor } = useColors();
return (
<section>
<h1>{title}</h1>
<button className="t-Button" onClick={() => removeColor(id)} >
<FaRemove />
</button>
<div style={{ height: 50, backgroundColor: color}} />
<StarRating selectedStars={rating} onRate={rating => rateColor(id, rating)} />
</section>
);
};

最後則是 AddColorForm 組件直接從 useColors 掛鉤取得 addColor 函式:

AddColorForm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const AddColorForm = () => {
const [title, setTitle] = useState("");
const [color, setColor] = useState("#000000");
const { addColor } = useColors();

const submit = e => {
e.preventDefault();
addColor(title, color);
setTitle("");
setColor("#000000");
};

return (
<form onSubmit={submit} >
<input
value={title}
onChange={ event => setTitle(event.target.value)}
type="text"
placeholder="color title..."
required
/>
<input
value={color}
onChange={ event => setColor(event.target.value)}
type="color"
required
/>
<button className="t-Button t-Button--hot margin-left-sm">Add</button>
</form>
);
};

Custom Hooks

在 AddColorForm 組件中還有一個地方可以改進的。當您的大型表單具有很多 input 元素時,您可能會複製並貼上以下兩行代碼:

1
2
value={title}
onChange={ event => setTitle(event.target.value)}

通過簡單地將這些屬性複制並粘貼到每個表單元素中,同時調整變數名稱,似乎很快速。但是,腦海裡總覺得,複製和粘貼代碼表明這些應改可以用函式更抽象化這些冗餘的工作。

我們可以創建受控表單組件所需的詳細信息打包到自定義的掛鉤(Custom Hook)中。 我們可以創建自己的 useInput 掛鉤,可以抽像出創建受控表單輸入所涉及的冗餘部分:

useInput
1
2
3
4
5
6
7
const useInput = initialValue => {
const [value, setValue] = useState(initialValue);
return [
{ value, onChange: e => setValue(e.target.value) },
() => setValue(initialValue)
];
};

現在可以在 AddColorForm 使用自定義的 useInput 掛鉤:

AddColorForm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const AddColorForm = () => {
const [titleProps, resetTitle] = useInput("");
const [colorProps, resetColor] = useInput("#000000");
const { addColor } = useColors();

const submit = e => {
e.preventDefault();
addColor(titleProps.value, colorProps.value);
resetTitle();
resetColor();
};

return (
<form onSubmit={submit} >
<input
{...titleProps}
type="text"
placeholder="color title..."
required
/>
<input
{...colorProps}
type="color"
required
/>
<button className="t-Button t-Button--hot margin-left-sm">Add</button>
</form>
);
};
  • 第 2、3 行改用自定義的 useInput 掛鉤。
  • 第 16、22 行我們不用重複輸入 value 與 onChange 事件。
  • 記得要一起修改 submit 函式中第 8 行 addColor 的參數與第 9 、10 行預設值設定。這個預設值是我們在調用 useInput 時設定的。

不必靠組件屬性將數據傳上傳下的,整個程式碼更清楚與更容易理解。

Oracle APEX: Color Organizer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
<div id="react-container"></div>

<script type="text/babel">
const { createContext, useContext, useState } = React;
const { render } = ReactDOM;
const { uuid, colorData } = Demo;

const ColorContext = createContext();
const useColors = () => useContext(ColorContext);

const ColorProvider = ({ children }) => {
const [colors, setColors] = useState(colorData);

const addColor = (title, color) =>
setColors([
...colors,
{
id: uuid(),
rating: 0,
title,
color
}
]);

const rateColor = (id, rating) =>
setColors(colors.map(color => (color.id === id ? { ...color, rating } : color ))
);

const removeColor = id => setColors(colors.filter(color => color.id !== id ));

return (
<ColorContext.Provider value={{ colors, addColor, removeColor, rateColor }}>
{children}
</ColorContext.Provider>
)
};

const FaStar = ({ color, onSelect = f => f }) => (
<span className="fa fa-star" style={{color}} onClick={onSelect} ></span>
);

const Star = ({ selected = false, onSelect = f => f }) => (
<FaStar color={selected ? "gold" : "grey"} onSelect={onSelect} />
);

const createArray = length => [...Array(length)];

const StarRating = ({ totalStars = 5, selectedStars = 0, onRate = f => f }) => {
return (
<>
{createArray(totalStars).map((n, i) => (
<Star
key={i}
selected={selectedStars > i}
onSelect={() => onRate(i + 1)}
/>
))}
<p style={{fontSize: "1.5em", margin: "10px"}}>{selectedStars} of {totalStars} stars</p>
</>
);
};

const FaRemove = () => <span className="fa fa-remove"></span>;

const Color = ({ id, title, color, rating }) => {
const { rateColor, removeColor } = useColors();
return (
<section>
<h1>{title}</h1>
<button className="t-Button" onClick={() => removeColor(id)} >
<FaRemove />
</button>
<div style={{ height: 50, backgroundColor: color}} />
<StarRating selectedStars={rating} onRate={rating => rateColor(id, rating)} />
</section>
);
};

const ColorList = () => {
const { colors } = useColors();

if(!colors.length) return <div>No Colors Listed.</div>;

return (
<div>{ colors.map(color => <Color key={color.id} {...color} />) } </div>
);
};

const useInput = initialValue => {
const [value, setValue] = useState(initialValue);
return [
{ value, onChange: e => setValue(e.target.value) },
() => setValue(initialValue)
];
};

const AddColorForm = () => {
const [titleProps, resetTitle] = useInput("");
const [colorProps, resetColor] = useInput("#000000");
const { addColor } = useColors();

const submit = e => {
e.preventDefault();
addColor(titleProps.value, colorProps.value);
resetTitle();
resetColor();
};

return (
<form onSubmit={submit} >
<input
{...titleProps}
type="text"
placeholder="color title..."
required
/>
<input
{...colorProps}
type="color"
required
/>
<button className="t-Button t-Button--hot margin-left-sm">Add</button>
</form>
);
};

const App = () => {
return (
<>
<AddColorForm />
<ColorList />
</>
);
};

render(
<ColorProvider>
<App />
</ColorProvider>,
document.getElementById("react-container")
);
</script>

Hook 是 React 16.8 中增加的新功能,它解決了 React 中我們過去在編寫與維護數萬個 component 組件時所遇到的各種看似不相關的問題。

Hook 可以讓你從 component 組件抽取 stateful 的邏輯,讓你不需要改變 component 階層就能重用 stateful 的邏輯,如此一來它就可以獨立地被測試和重複使用。