GraphQL 和 TypeScript 的使用率都在爆炸式增長,并且當兩者與 React 結合應用時,它們在一起可以創(chuàng)造理想的開發(fā)體驗。
GraphQL 改變了我們對 API 的思考方式;利用 GrahpQL 直觀的鍵 / 值對匹配,客戶端可以精確請求所需的數據來顯示在網頁或移動應用屏幕上。TypeScript 則向變量添加了靜態(tài)類型來擴展 JavaScript,從而減少了錯誤并提高了可讀性。
本文將引導你使用公共的 SpaceX GraphQL API,使用 React 和 Apollo 構建一個客戶端應用程序,展示有關火箭發(fā)射的信息。我們將自動為查詢生成 TypeScript 類型,并使用 React Hooks 執(zhí)行這些查詢。
假定你對 React、GraphQL 和 TypeScript 有所了解,我們將重點介紹如何將它們集成在一起以構建一個正常運作的應用程序。
如果你在哪里卡住了,可以參考源代碼 [1] 或查看應用的演示 [2]。
GraphQL API 需要被強類型化,并且從單個端點提供數據??蛻舳嗽诖硕它c上調用一個 GET 請求,就可以接收一個后端的完全自注釋的表示,以及所有可用數據和相應的類型。
我們可以使用 GraphQL Code Generator[3] 在 Web 應用目錄中掃描查詢文件,并將它們與 GraphQL API 提供的信息匹配,從而為所有請求數據創(chuàng)建 TypeScript 類型。使用 GraphQL,我們可以免費自動輸入 React 組件的 props。這樣可以減少錯誤,并加快產品迭代速度。
npx create-react-app graphql-typescript-react --typescript
// NOTE - you will need Node v8.10.0+ and NPM v5.2+
使用 --typescript 標志,CRA 將生成你的文件以及.ts 和.tsx,并將創(chuàng)建一個 tsconfig.json 文件。
cd graphql-typescript-react
現在我們可以安裝其他依賴項。我們的應用將使用 Apollo 來執(zhí)行 GraphQL API 請求。Apollo 所需的庫是 apollo-boost、react-apollo、react-apollo-hooks、graphql-tag 和 graphql。
yarn add apollo-boost react-apollo react-apollo-hooks graphql-tag graphql
yarn add -D @graphql-codegen/cli
$(npm bin)/graphql-codegen init
這將啟動 CLI 向導。請執(zhí)行以下步驟:
現在,在 CLI 中運行 yarn 命令,安裝 CLI 工具添加到 package.json 中的插件。
overwrite: true
schema: 'https://spacexdata.herokuapp.com/graphql'
documents: './src/components/**/*.ts'
generates:
src/generated/graphql.tsx:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-react-apollo'
config:
withHooks: true
GraphQL 的一大好處是它使用了聲明性數據獲取。我們能夠編寫出一些與使用它們的組件并存的查詢,并且 UI 能夠準確地請求它需要渲染的內容。
使用 REST API 時,我們需要查找處于(或不處于)最新狀態(tài)的文檔。如果 REST 出現任何問題,我們需要針對 API 和 console.log 結果發(fā)起請求以調試數據。
GraphQL 允許你在 UI 中訪問 URL,查看完全定義的 schema 并針對它執(zhí)行請求,從而解決了這個問題。請查看要使用的數據 [4]。
盡管我們有大量的 SpaceX 數據可供使用,但我們僅顯示有關火箭發(fā)射的信息。我們將有兩個主要組件:
對于第一個組件,我們將查詢 launches 鍵,并請求 flight_number、mission_name 和 launch_year。我們將這些數據顯示在一個列表中,當用戶單擊其中一個項目時,我們將根據 launch 鍵查詢關于這次火箭發(fā)射的更大數據集。下面我們在 GraphQL 游樂場中測試我們的第一個查詢。
import gql from 'graphql-tag';
export const QUERY_LAUNCH_LIST = gql`
query LaunchList {
launches {
flight_number
mission_name
launch_year
}
}
`;
我們的其他查詢將基于 flight_number,獲得有關單次發(fā)射的更詳細數據。由于這將通過用戶交互動態(tài)生成,因此我們將需要使用 GraphQL 變量。我們還可以在游樂場上用變量測試查詢。
在查詢名稱旁邊指定變量,前面帶上 $ 及其類型。然后你就可以在 body 內使用變量了。針對查詢,我們通過傳遞 $id 變量(其類型為 String!)來設置火箭發(fā)射的 ID。
我們將 id 作為一個變量傳遞,該變量對應于 LaunchList 查詢中的 flight_number。LaunchProfile 查詢還將包含嵌套的對象 / 類型,在這里我們可以在方括號內指定鍵來獲取值。
例如,發(fā)射信息包含了一個 rocket 定義(LaunchRocket 類型),我們將要求它提供 rocket_name 和 rocket_type。要了解更多可用于 LaunchRocket 的字段信息,你可以使用側邊的 schema 導航器來了解可用數據。
import gql from 'graphql-tag';
export const QUERY_LAUNCH_PROFILE = gql`
query LaunchProfile($id: String!) {
launch(id: $id) {
flight_number
mission_name
launch_year
launch_success
details
launch_site {
site_name
}
rocket {
rocket_name
rocket_type
}
links {
flickr_images
}
}
}
`;
yarn codegen
在 src/generation/graphql.ts 內部,你將找到定義應用程序所需的所有類型,以及用于獲取 GraphQL 端點以檢索該數據的對應查詢。
這個文件通常會很大,但是充滿了有價值的信息。我建議花些時間瀏覽一下,并了解我們的 codegen 完全基于 GraphQL schema 所創(chuàng)建的所有類型。
比如說檢查 type Launch,它是 GraphQL 的 Launch 對象的 TypeScript 表示形式,我們會在游樂場上與之交互。還可以滾動到文件的底部,查看專門為我們將要執(zhí)行的查詢生成的代碼——它已創(chuàng)建了組件、HOC、類型化的 props/ 查詢和類型化的 hooks。
在 src/index.tsx 中,我們需要初始化 Apollo 客戶端,并使用 ApolloProvider 組件將我們的 client 添加到 React 的上下文中。我們還需要 ApolloProviderHooks 組件以在 hooks 中啟用上下文。
import React from 'react';
import ReactDOM from 'react-dom';
import ApolloClient from 'apollo-boost';
import { ApolloProvider } from 'react-apollo';
import { ApolloProvider as ApolloHooksProvider } from 'react-apollo-hooks';
import './index.css';
import App from './App';
const client = new ApolloClient({
uri: 'https://spacexdata.herokuapp.com/graphql',
});
ReactDOM.render(
<ApolloProvider client={client}>
<ApolloHooksProvider client={client}>
<App />
</ApolloHooksProvider>
</ApolloProvider>,
document.getElementById('root'),
);
現在我們已經準備好了通過 Apollo 執(zhí)行 GraphQL 查詢所需的一切內容。
在 src/components/LaunchList/index.tsx 內,我們將創(chuàng)建一個函數組件,其使用生成的 useLaunchListQuery hook。查詢 hooks 返回 data、loading 和 error 值。我們將檢查容器組件中的 loading 和 error,并將 data 傳遞給我們的演示組件。
我們將此組件用作一個容器 / 智能組件,從而保持關注點的分離;我們還將數據傳遞給表示 / 啞組件,該組件僅顯示給出的內容。我們還將在等待數據時顯示基本的加載和錯誤狀態(tài)。
import * as React from 'react';
import { useLaunchListQuery } from '../../generated/graphql';
import LaunchList from './LaunchList';
const LaunchListContainer = () => {
const { data, error, loading } = useLaunchListQuery();
if (loading) {
return <div>Loading...</div>;
}
if (error || !data) {
return <div>ERROR</div>;
}
return <LaunchList data={data} />;
};
export default LaunchListContainer;
我們的演示組件將使用我們的類型化 data 對象來構建 UI。我們使用<ol>創(chuàng)建一個有序列表,然后映射到發(fā)射信息中,以顯示 mission_name 和 launch_year。
import * as React from 'react';
import { LaunchListQuery } from '../../generated/graphql';
import './styles.css';
interface Props {
data: LaunchListQuery;
}
const className = 'LaunchList';
const LaunchList: React.FC<Props> = ({ data }) => (
<div className={className}>
<h3>Launches</h3>
<ol className={`${className}__list`}>
{!!data.launches &&
data.launches.map(
(launch, i) =>
!!launch && (
<li key={i} className={`${className}__item`}>
{launch.mission_name} ({launch.launch_year})
</li>
),
)}
</ol>
</div>
);
export default LaunchList;
如果你使用的是 VS Code,由于我們正在使用 TypeScript,因此 IntelliSense 會準確顯示可用的值并提供自動完成列表。它還會警告我們正在使用的數據可以為 null 還是 undefined。
這么神奇?編輯器會自動幫我們編程。另外,如果需要定義類型或函數,可以按 Cmd + t,鼠標指針懸停其上,它將為你提供所有詳細信息。
.LaunchList {
height: 100vh;
overflow: hidden auto;
background-color: #ececec;
width: 300px;
padding-left: 20px;
padding-right: 20px;
}
.LaunchList__list {
list-style: none;
margin: 0;
padding: 0;
}
.LaunchList__item {
padding-top: 20px;
padding-bottom: 20px;
border-top: 1px solid #919191;
cursor: pointer;
}
現在我們將構建配置組件,以顯示有關火箭發(fā)射的更多詳細信息。該組件的 index.tsx 文件基本是一樣的,只是我們使用的是 Profile 查詢和組件。我們還將一個變量傳遞給我們的 React hook 以獲取發(fā)射 ID。目前我們將其硬編碼為'42',然后在布局好應用后添加動態(tài)功能。
import * as React from 'react';
import { useLaunchProfileQuery } from '../../generated/graphql';
import LaunchProfile from './LaunchProfile';
const LaunchProfileContainer = () => {
const { data, error, loading } = useLaunchProfileQuery(
{ variables: { id: '42' } }
);
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>ERROR</div>;
}
if (!data) {
return <div>Select a flight from the panel</div>;
}
return <LaunchProfile data={data} />;
};
export default LaunchProfileContainer;
現在我們需要創(chuàng)建演示組件。它將在用戶界面頂部顯示火箭發(fā)射的名稱和詳細信息,然后在說明下方顯示一個發(fā)射圖像網格。
import * as React from 'react';
import { LaunchProfileQuery } from '../../generated/graphql';
import './styles.css';
interface Props {
data: LaunchProfileQuery;
}
const className = 'LaunchProfile';
const LaunchProfile: React.FC<Props> = ({ data }) => {
if (!data.launch) {
return <div>No launch available</div>;
}
return (
<div className={className}>
<div className={`${className}__status`}>
<span>Flight {data.launch.flight_number}: </span>
{data.launch.launch_success ? (
<span className={`${className}__success`}>Success</span>
) : (
<span className={`${className}__failed`}>Failed</span>
)}
</div>
<h1 className={`${className}__title`}>
{data.launch.mission_name}
{data.launch.rocket &&
` (${data.launch.rocket.rocket_name} | ${data.launch.rocket.rocket_type})`}
</h1>
<p className={`${className}__description`}>{data.launch.details}</p>
{!!data.launch.links && !!data.launch.links.flickr_images && (
<div className={`${className}__image-list`}>
{data.launch.links.flickr_images.map(image =>
image ? <img src={image} className={`${className}__image`} key={image} /> : null,
)}
</div>
)}
</div>
);
};
export default LaunchProfile;
.LaunchProfile {
height: 100vh;
max-height: 100%;
width: calc(100vw - 300px);
overflow: hidden auto;
padding-left: 20px;
padding-right: 20px;
}
.LaunchProfile__status {
margin-top: 40px;
}
.LaunchProfile__title {
margin-top: 0;
margin-bottom: 4px;
}
.LaunchProfile__success {
color: #2cb84b;
}
.LaunchProfile__failed {
color: #ff695e;
}
.LaunchProfile__image-list {
display: grid;
grid-gap: 20px;
grid-template-columns: repeat(2, 1fr);
margin-top: 40px;
padding-bottom: 100px;
}
.LaunchProfile__image {
width: 100%;
}
import React from 'react';
import LaunchList from './components/LaunchList';
import LaunchProfile from './components/LaunchProfile';
import './App.css';
const App = () => {
return (
<div className='App'>
<LaunchList />
<LaunchProfile />
</div>
);
};
export default App;
.App {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
}
在終端中執(zhí)行 yarn start,在瀏覽器中轉至 http://localhost:3000,你就應該能看到應用的基本版本了!
現在我們需要添加一項功能,以在用戶單擊面板中的項目時獲取完整的火箭發(fā)射相關數據。我們將在 App 組件中創(chuàng)建一個 hook 來跟蹤火箭 ID,并將其傳遞給 LaunchProfile 組件以重新獲取發(fā)射相關數據。
我們在 src/App.tsx 中添加 useState 來維護和更新 ID 的狀態(tài)。當用戶從列表中選擇一個 ID 時,我們還將使用名為 handleIdChange 的 useCallback 作為單擊處理程序來更新 ID。我們將這個 id 傳遞給 LaunchProfile,然后將 handleIdChange 傳遞給<LaunchList />。
const App = () => {
const [id, setId] = React.useState(42);
const handleIdChange = React.useCallback(newId => {
setId(newId);
}, []);
return (
<div className='App'>
<LaunchList handleIdChange={handleIdChange} />
<LaunchProfile id={id} />
</div>
);
};
export interface OwnProps {
handleIdChange: (newId: number) => void;
}
interface Props extends OwnProps {
data: LaunchListQuery;
}
// ...
const LaunchList: React.FC<Props> = ({ data, handleIdChange }) => (
// ...
<li
key={i}
className={`${className}__item`}
onClick={() => handleIdChange(launch.flight_number!)}
>
在 LaunchList/index.tsx 內部,請確保導入 OwnProps 聲明以類型化要傳遞到容器組件的 props,然后將這些 props 散布到<LaunchList data = {data} {... props} />中。
interface OwnProps {
id: number;
}
const LaunchProfileContainer = ({ id }: OwnProps) => {
const { data, error, loading, refetch } = useLaunchProfileQuery({
variables: { id: String(id) },
});
React.useEffect(() => {
refetch();
}, [id]);
配置好應用后,我們可以看到開發(fā)速度是非??斓摹N覀兛梢暂p松構建數據驅動的 UI。GraphQL 允許我們定義組件中所需的數據,并且可以將其無縫用作組件中的 props。生成的 TypeScript 定義為我們編寫的代碼提供了極高的信心水平。
如果你希望深入研究該項目,那么下一步將是使用 API中的額外字段來添加分頁和更多的數據連接。要對火箭發(fā)射列表進行分頁,你需要獲得當前列表的長度,并將 offset 變量傳遞給 LaunchList 查詢。
我鼓勵你更深入地研究它并編寫自己的查詢,以鞏固本文提出的概念。
聯(lián)系客服