在這份教學中,我們將會使用 GraphQL mutations 來建置一個遊戲。這個遊戲的目標是在九個正方形的網格中尋找一份藏起來的寶藏。我們將會給玩家們三次嘗試去找寶藏。這應該可以給我們 Relay 完整的樣貌 – 從在伺服器上的 GraphQL schema,到客戶端上的 React 應用程式。
讓我們使用 Relay Starter Kit 作為基底來開始一個專案。
git clone https://github.com/relayjs/relay-starter-kit.git relay-treasurehunt cd relay-treasurehunt npm install
我們需要一個地方來藏我們的寶藏,一個檢查寶藏藏匿點的方法,還有一個追蹤我們剩餘的回合數的方法。為了教學的目的,我們會把這些資料藏在記憶體中。
/** * ./data/database.js */ // Model types export class Game {} export class HidingSpot {} // Mock data const game = new Game(); game.id = '1'; const hidingSpots = []; (function() { let hidingSpot; const indexOfSpotWithTreasure = Math.floor(Math.random() * 9); for (let i = 0; i < 9; i++) { hidingSpot = new HidingSpot(); hidingSpot.id = `${i}`; hidingSpot.hasTreasure = (i === indexOfSpotWithTreasure); hidingSpot.hasBeenChecked = false; hidingSpots.push(hidingSpot); } })(); let turnsRemaining = 3; export function checkHidingSpotForTreasure(id) { if (hidingSpots.some(hs => hs.hasTreasure && hs.hasBeenChecked)) { return; } turnsRemaining--; const hidingSpot = getHidingSpot(id); hidingSpot.hasBeenChecked = true; } export function getHidingSpot(id) { return hidingSpots.find(hs => hs.id === id); } export function getGame() { return game; } export function getHidingSpots() { return hidingSpots; } export function getTurnsRemaining() { return turnsRemaining; }
我們目前為止寫在這邊的是一個 mock 資料庫介面。我們可以想像把這個綁上到一個真實的資料庫,不過現在讓我們繼續往下。
GraphQL schema 描述你的資料模型,並提供一個 GraphQL 伺服器與相關聯的一組知道要如何抓取資料的 resolve 方法。我們將會使用 graphql-js 和 graphql-relay-js 來建構我們的 schema。
讓我們來打開 starter kit 的 schema,並用我們剛建立的那一個來置換資料庫的 import:
/** * ./data/schema.js */ /* ... */ import { Game, HidingSpot, checkHidingSpotForTreasure, getGame, getHidingSpot, getHidingSpots, getTurnsRemaining, } from './database';
在此刻,你可以刪除 ./data/schema.js
中直到 queryType
之前的所有東西。
接著,讓我們來定義一個 node interface 和 type。我們只需要提供一個方式讓 Relay 能從一個 object 映射到與那個 object 相關聯的 GraphQL type,並從一個 global ID 映射到它指向的 object:
const {nodeInterface, nodeField} = nodeDefinitions( (globalId) => { const {type, id} = fromGlobalId(globalId); if (type === 'Game') { return getGame(id); } else if (type === 'HidingSpot') { return getHidingSpot(id); } else { return null; } }, (obj) => { if (obj instanceof Game) { return gameType; } else if (obj instanceof HidingSpot) { return hidingSpotType; } else { return null; } } );
再下來,讓我們來定義我們的 game 和 hiding spot types,還有在它們上面可以存取的 fields。
const gameType = new GraphQLObjectType({ name: 'Game', description: 'A treasure search game', fields: () => ({ id: globalIdField('Game'), hidingSpots: { type: hidingSpotConnection, description: 'Places where treasure might be hidden', args: connectionArgs, resolve: (game, args) => connectionFromArray(getHidingSpots(), args), }, turnsRemaining: { type: GraphQLInt, description: 'The number of turns a player has left to find the treasure', resolve: () => getTurnsRemaining(), }, }), interfaces: [nodeInterface], }); const hidingSpotType = new GraphQLObjectType({ name: 'HidingSpot', description: 'A place where you might find treasure', fields: () => ({ id: globalIdField('HidingSpot'), hasBeenChecked: { type: GraphQLBoolean, description: 'True if this spot has already been checked for treasure', resolve: (hidingSpot) => hidingSpot.hasBeenChecked, }, hasTreasure: { type: GraphQLBoolean, description: 'True if this hiding spot holds treasure', resolve: (hidingSpot) => { if (hidingSpot.hasBeenChecked) { return hidingSpot.hasTreasure; } else { return null; // 噓... 這是秘密! } }, }, }), interfaces: [nodeInterface], });
因為一個 game 可以有許多個 hiding spots,我們需要建立一個 connection,讓我們可以使用來 把他們連結在一起。
const {connectionType: hidingSpotConnection} = connectionDefinitions({name: 'HidingSpot', nodeType: hidingSpotType});
現在讓我們來把這些 types 連結到 root query type。
const queryType = new GraphQLObjectType({ name: 'Query', fields: () => ({ node: nodeField, game: { type: gameType, resolve: () => getGame(), }, }), });
隨著 queries 已經完成,讓我們開始著手我們唯一的 mutation:消耗一個回合來檢查一個 spot 有沒有寶藏的那一個。在這裡,我們定義給 mutation 的 input (spot 的 id 用來檢查寶藏) 和在 mutation 發生之後所有客戶端可能會可能想要更新的 fields 清單。最後,我們實作一個方法來執行背後的 mutation。
const CheckHidingSpotForTreasureMutation = mutationWithClientMutationId({ name: 'CheckHidingSpotForTreasure', inputFields: { id: { type: new GraphQLNonNull(GraphQLID) }, }, outputFields: { hidingSpot: { type: hidingSpotType, resolve: ({localHidingSpotId}) => getHidingSpot(localHidingSpotId), }, game: { type: gameType, resolve: () => getGame(), }, }, mutateAndGetPayload: ({id}) => { const localHidingSpotId = fromGlobalId(id).id; checkHidingSpotForTreasure(localHidingSpotId); return {localHidingSpotId}; }, });
讓我們來把剛建立的 mutation 連結到 root mutation type:
const mutationType = new GraphQLObjectType({ name: 'Mutation', fields: () => ({ checkHidingSpotForTreasure: CheckHidingSpotForTreasureMutation, }), });
最後,建構我們的 schema (它的起始 query type 是我們先前定義過的 query type) 並 export 它。
export const Schema = new GraphQLSchema({ query: queryType, mutation: mutationType });
在我們更深入之前,我們需要把我們的可執行 schema serialize 成 JSON,才能讓 Relay.QL transpiler 使用,接著啟動伺服器。從 command line:
npm run update-schema
npm start
讓我們微調一下檔案 ./js/routes/AppHomeRoute.js
,以把我們的 game 綁到 schema 的 game
root field:
export default class extends Relay.Route { static queries = { game: () => Relay.QL`query { game }`, }; static routeName = 'AppHomeRoute'; }
接下來,讓我們在 ./js/mutations/CheckHidingSpotForTreasureMutation.js
建立一個檔案並建立 Relay.Mutation
的子類別叫做 CheckHidingSpotForTreasureMutation
來放我們的 mutation 實作:
import Relay from 'react-relay'; export default class CheckHidingSpotForTreasureMutation extends Relay.Mutation { static fragments = { game: () => Relay.QL` fragment on Game { id, turnsRemaining, } `, hidingSpot: () => Relay.QL` fragment on HidingSpot { id, } `, }; getMutation() { return Relay.QL`mutation{checkHidingSpotForTreasure}`; } getCollisionKey() { return `check_${this.props.game.id}`; } getFatQuery() { return Relay.QL` fragment on CheckHidingSpotForTreasurePayload @relay(pattern: true) { hidingSpot { hasBeenChecked, hasTreasure, }, game { turnsRemaining, }, } `; } getConfigs() { return [{ type: 'FIELDS_CHANGE', fieldIDs: { hidingSpot: this.props.hidingSpot.id, game: this.props.game.id, }, }]; } getVariables() { return { id: this.props.hidingSpot.id, }; } getOptimisticResponse() { return { game: { turnsRemaining: this.props.game.turnsRemaining - 1, }, hidingSpot: { id: this.props.hidingSpot.id, hasBeenChecked: true, }, }; } }
最後,讓我們在 ./js/components/App.js
中把它們全部結合在一起:
import CheckHidingSpotForTreasureMutation from '../mutations/CheckHidingSpotForTreasureMutation'; import React from 'react'; import Relay from 'react-relay'; class App extends React.Component { _getHidingSpotStyle(hidingSpot) { let color; if (this.props.relay.hasOptimisticUpdate(hidingSpot)) { color = 'lightGrey'; } else if (hidingSpot.hasBeenChecked) { if (hidingSpot.hasTreasure) { color = 'blue'; } else { color = 'red'; } } else { color = 'black'; } return { backgroundColor: color, cursor: this._isGameOver() ? null : 'pointer', display: 'inline-block', height: 100, marginRight: 10, width: 100, }; } _handleHidingSpotClick(hidingSpot) { if (this._isGameOver()) { return; } this.props.relay.commitUpdate( new CheckHidingSpotForTreasureMutation({ game: this.props.game, hidingSpot, }) ); } _hasFoundTreasure() { return ( this.props.game.hidingSpots.edges.some(edge => edge.node.hasTreasure) ); } _isGameOver() { return !this.props.game.turnsRemaining || this._hasFoundTreasure(); } renderGameBoard() { return this.props.game.hidingSpots.edges.map(edge => { return ( <div key={edge.node.id} onClick={this._handleHidingSpotClick.bind(this, edge.node)} style={this._getHidingSpotStyle(edge.node)} /> ); }); } render() { let headerText; if (this.props.relay.getPendingTransactions(this.props.game)) { headerText = '\u2026'; } else if (this._hasFoundTreasure()) { headerText = 'You win!'; } else if (this._isGameOver()) { headerText = 'Game over!'; } else { headerText = 'Find the treasure!'; } return ( <div> <h1>{headerText}</h1> {this.renderGameBoard()} <p>Turns remaining: {this.props.game.turnsRemaining}</p> </div> ); } } export default Relay.createContainer(App, { fragments: { game: () => Relay.QL` fragment on Game { turnsRemaining, hidingSpots(first: 9) { edges { node { hasBeenChecked, hasTreasure, id, ${CheckHidingSpotForTreasureMutation.getFragment('hidingSpot')}, } } }, ${CheckHidingSpotForTreasureMutation.getFragment('game')}, } `, }, });
在 relay-examples repository 中以找到一份可運作的 treasure hunt 副本。
現在,我們現在已經完成這份教學,讓我們深入看看建置一個 GraphQL 客戶端框架代表什麼意思還有要如何把它跟比較傳統的 REST 系統的客戶端做比較。