1、項目背景
源於2019年11月16日成都Web全棧大會上尹吉峰老師的GraphQL的分享,讓我產生了濃厚的興趣。幾經研究、學習,做了個實踐的小項目。
學習資料:
https://typescript.bootcss.com/basic-types.html
https://www.apollographql.com/docs/react/
就代碼做以下分析。
2、項目目錄
項目分為前端和后端兩部分(目錄client和server)。如圖所示。

使用技術棧:
client: react hooks + typescript + apollo + graphql + antd
server: koa2 + graphql + koa-graphql + mongoose
3、項目搭建及源碼實現
數據庫部分
使用的是mongodb數據庫,這里對於該數據庫的安裝等不做贅述。
默認已經 具備mongodb的環境。啟動數據庫。
到mongodb安裝路徑下,如C:\Program Files\MongoDB\Server\4.2\bin
打開終端,執行命令:
mongod --dbpath=./data
創建項目總目錄:react-graphql-project,並進入目錄。
后端部分
1)創建項目
mkdir server && cd server npm init -y
2) 安裝項目依賴
yarn add koa koa-grphql koa2-cors koa-mount koa-logger graphql
3) 配置啟動命令
package.json文件
{ "name": "server", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "nodemon index.js" }, "keywords": [], "author": "zhangyanling", "license": "MIT", "dependencies": { "graphql": "^14.5.8", "koa": "^2.11.0", "koa-graphql": "^0.8.0", "koa-logger": "^3.2.1", "koa-mount": "^4.0.0", "koa2-cors": "^2.0.6", "mongoose": "^5.7.11" } }
4)業務開發
入口文件index.js:
const Koa = require('koa');
const mount = require('koa-mount');
const graphqlHTTP = require('koa-graphql');
const cors = require('koa2-cors'); // 解決跨域
const logger = require('koa-logger'); // 日志輸出
const myGraphQLSchema = require('./schema');
const app = new Koa();
app.use(logger())
app.use(cors({
origin: '*',
allowMethods: ['GET', 'POST', 'DELETE', 'PUT', 'OPTIONS']
}))
app.use(mount('/graphql', graphqlHTTP({
schema: myGraphQLSchema,
graphiql: true // 開啟graphiql可視化操作ide
})))
app.listen(4000, () => {
console.log('server started on 4000')
})
數據庫連接,創建model文件 model.js:
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
// 創建數據庫連接
const conn = mongoose.createConnection('mongodb://localhost/graphql',{ useNewUrlParser: true, useUnifiedTopology: true });
conn.on('open', () => console.log('數據庫連接成功!'));
conn.on('error', (error) => console.log(error));
// 用於定義表結構
const CategorySchema = new Schema({
name: String
});
// 增刪改查
const CategoryModel = conn.model('Category', CategorySchema);
const ProductSchema = new Schema({
name: String,
category: {
type: Schema.Types.ObjectId, // 外鍵
ref: 'Category'
}
});
const ProductModel = conn.model('Product', ProductSchema);
module.exports = {
CategoryModel,
ProductModel
}
schema.js文件:
const graphql = require('graphql');
const { CategoryModel, ProductModel } = require('./model');
const {
GraphQLObjectType,
GraphQLString,
GraphQLSchema,
GraphQLList,
GraphQLNonNull
} = graphql
const Category = new GraphQLObjectType({
name: 'Category',
fields: () => (
{
id: { type: GraphQLString },
name: { type: GraphQLString },
products: {
type: new GraphQLList(Product),
async resolve(parent){
let result = await ProductModel.find({ category: parent.id })
return result
}
}
}
)
})
const Product = new GraphQLObjectType({
name: 'Product',
fields: () => (
{
id: { type: GraphQLString },
name: { type: GraphQLString },
category: {
type: Category,
async resolve(parent){
let result = await CategoryModel.findById(parent.category)
return result
}
}
}
)
})
const RootQuery = new GraphQLObjectType({
name: 'RootQuery',
fields: {
getCategory: {
type: Category,
args: {
id: { type: new GraphQLNonNull(GraphQLString) }
},
async resolve(parent, args){
let result = await CategoryModel.findById(args.id)
return result
}
},
getCategories: {
type: new GraphQLList(Category),
args: {},
async resolve(parent, args){
let result = await CategoryModel.find()
return result
}
},
getProduct: {
type: Product,
args: {
id: { type: new GraphQLNonNull(GraphQLString) }
},
async resolve(parent, args){
let result = await ProductModel.findById(args.id)
return result
}
},
getProducts: {
type: new GraphQLList(Product),
args: {},
async resolve(parent, args){
let result = await ProductModel.find()
return result
}
}
}
})
const RootMutation = new GraphQLObjectType({
name: 'RootMutation',
fields: {
addCategory: {
type: Category,
args: {
name: { type: new GraphQLNonNull(GraphQLString) }
},
async resolve(parent, args){
let result = await CategoryModel.create(args)
return result
}
},
addProduct: {
type: Product,
args: {
name: { type: new GraphQLNonNull(GraphQLString) },
category: { type: new GraphQLNonNull(GraphQLString) }
},
async resolve(parent, args){
let result = await ProductModel.create(args)
return result
}
},
deleteProduct: {
type: Product,
args: {
id: { type: new GraphQLNonNull(GraphQLString) },
},
async resolve(parent, args){
let result = await ProductModel.deleteOne({"_id": args.id})
return result
}
}
}
})
module.exports = new GraphQLSchema({
query: RootQuery,
mutation: RootMutation
})
5)啟動項目
yarn start
訪問 http://localhost:4000/graphql 看到數據庫操作playground界面。可進行一系列數據庫crud操作。
前端部分
1)創建項目
npx create-react-app react-graphql-project --template typescript
生成項目后刪除無用的文件。
2) 需要配置webpack
yarn add react-app-rewired customize-cra
更改package.json文件的scripts啟動命令
"scripts": { "start": "react-app-rewired start", "build": "react-app-rewired build", "test": "react-app-rewired test" }
然后在根目錄下新建config-overrides.js文件,以做webpack的相關配置。
安裝前端UI組件庫antd,並配置按需加載、路徑別名支持等。
yarn add antd babel-plugin-import
config-overrides.js
const { override, fixBabelImports, addWebpackAlias } = require('customize-cra');
const path = require('path')
module.exports = override(
fixBabelImports('import', {
libraryName: 'antd',
libraryDirectory: 'es',
style: 'css'
}),
addWebpackAlias({
"@": path.resolve(__dirname, "src/")
})
)
因為ts無法識別,還需配置tconfig.json 文件。
新建paths.json文件
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }
更改tconfig.json
{ "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react" }, "include": [ "./src/**/*" ], "extends": "./paths.json" }
重啟項目后生效。
3)安裝其他項目依賴
yarn add graphql apollo-boost @apollo/react-hooks
yarn add react-router-dom @types/react-router-dom
4) 業務開發
入口文件index.tsx:
import React from 'react'; import ReactDOM from 'react-dom'; import ApolloClient from 'apollo-boost'; import { ApolloProvider } from '@apollo/react-hooks'; import App from './router'; import * as serviceWorker from './serviceWorker'; // 創建apollo客戶端 const client = new ApolloClient({ uri: 'http://localhost:4000/graphql' }) ReactDOM.render( <ApolloProvider client={client}> <App /> </ApolloProvider>, document.getElementById('root')); serviceWorker.unregister();
路由文件router.js:
import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Spin } from 'antd';
// 懶加載組件
const Layouts = lazy(() => import('@/components/layouts'));
const ProductList = lazy(() => import('@/pages/productlist'));
const ProductDetail = lazy(() => import('@/pages/productdetail'));
const RouterComponent = () => {
return (
<Router>
<Suspense fallback={<Spin size="large" />}>
<Layouts>
<Switch>
<Route path="/" exact={true} component={ProductList}></Route>
<Route path="/detail/:id" component={ProductDetail}></Route>
</Switch>
</Layouts>
</Suspense>
</Router>
)
};
export default RouterComponent;
定義類型文件types.tsx:
export interface Category{ id?: string; name?: string; products: Array<Product> } export interface Product{ id?:string; name?: string; category?: Category; categoryId?: string | []; }
開發布局組件 src/components/layouts
import React from 'react'; import { Layout, Menu } from 'antd'; import { Link } from 'react-router-dom'; const { Header, Content, Footer } = Layout const Layouts: React.FC = (props) => ( <Layout className="layout"> <Header> <div className="logo" /> <Menu theme="dark" mode="horizontal" defaultSelectedKeys={['1']} style={{ lineHeight: '64px' }} > <Menu.Item key="1"><Link to="/">商品管理</Link></Menu.Item> </Menu> </Header> <Content style={{ padding: '50px 50px 0 50px' }}> <div style={{ background: '#fff', padding: 24, minHeight: 280 }}> {props.children} </div> </Content> <Footer style={{ textAlign: 'center' }}> ©2019 Created by zhangyanling. </Footer> </Layout> ) export default Layouts;
定義gql查詢語句文件 api.tsx:
import { gql } from 'apollo-boost';
export const GET_PRODUCTS = gql`
query{
getProducts{
id
name
category{
id
name
products{
id
name
}
}
}
}
`;
// 查詢所有的上屏分類和產品
export const CATEGORIES_PRODUCTS = gql`
query{
getCategories{
id
name
products{
id
name
}
}
getProducts{
id
name
category{
id
name
products{
id
name
}
}
}
}
`;
// 添加產品
export const ADD_PRODUCT = gql`
mutation($name:String!, $categoryId:String!){
addProduct(name: $name, category: $categoryId){
id
name
category{
id
name
}
}
}
`;
// 根據id刪除產品
export const DELETE_PRODUCT = gql`
mutation($id: String!){
deleteProduct(id: $id){
id,
name
}
}
`;
// 根據id查詢商品詳情及相應商品分類及所屬分類全部商品
export const GET_PRODUCT = gql`
query($id: String!){
getProduct(id: $id){
id,
name,
category{
id,
name,
products{
id,
name
}
}
}
}
`;
開發商品列表組件ProductList:
已經實現商品列表展示、刪除商品、新增商品等功能。
import React, { useState } from 'react';
import { Table, Modal, Row, Col, Button, Divider, Tag, Form, Input, Select, Popconfirm } from 'antd';
import { Link } from 'react-router-dom';
import { useQuery, useMutation } from '@apollo/react-hooks';
import { CATEGORIES_PRODUCTS, GET_PRODUCTS, ADD_PRODUCT, DELETE_PRODUCT } from '@/api';
import { Product, Category } from '@/types';
const { Option } = Select;
/**
* 商品列表
*/
const ProductList: React.FC = () => {
let [visible, setVisible] = useState<boolean>(false);
let [pageSize, setPageSize] = useState<number|undefined>(10);
let [current, setCurrent] = useState<number|undefined>(1)
const { loading, error, data } = useQuery(CATEGORIES_PRODUCTS);
const [deleteProduct] = useMutation(DELETE_PRODUCT);
if(error) return <p>加載發生錯誤</p>;
if(loading) return <p>加載中...</p>;
const { getCategories, getProducts } = data
const confirm = async (event?:any, record?:Product) => {
// console.log("詳情", record);
await deleteProduct({
variables: {
id: record?.id
},
refetchQueries: [{
query: GET_PRODUCTS
}]
})
setCurrent(1)
}
const columns = [
{
title: "商品ID",
dataIndex: "id"
},
{
title: "商品名稱",
dataIndex: "name"
},
{
title: "商品分類",
dataIndex: "category",
render: (text: any) => {
let color = ''
const tagName = text.name;
if(tagName === '服飾'){
color = 'red'
} else if(tagName === '食品') {
color = 'green'
} else if(tagName === '數碼'){
color = 'blue'
} else if(tagName === '母嬰'){
color = 'purple'
}
return (
<Tag color={color}>{text.name}</Tag>
)
}
},
{
title: "操作",
render: (text: any, record: any) => (
<span>
<Link to={`/detail/${record.id}`}>詳情</Link>
{/* <Divider type="vertical" /> */}
{/* <a style={{color: 'orange'}}>修改</a> */}
<Divider type="vertical" />
<Popconfirm
title="確定刪除嗎?"
onConfirm={(event) => confirm(event, record)}
okText="確定"
cancelText="取消"
>
<a style={{color:'red'}}>刪除</a>
</Popconfirm>
</span>
)
}
];
const handleOk = () => {
setVisible(false)
}
const handleCancel = () => {
setVisible(false)
}
const handleChange = (pagination: { current?:number, pageSize?:number}) => {
// console.log(pagination)
const { current, pageSize } = pagination
setPageSize(pageSize)
setCurrent(current)
}
return (
<div>
<Row style={{padding: '0 0 20px 0'}}>
<Col span={24}>
<Button type="primary" onClick={() => setVisible(true)}>新增</Button>
</Col>
</Row>
<Row>
<Col span={24}>
<Table
columns={columns}
dataSource={getProducts}
rowKey="id"
pagination={{
current: current,
pageSize: pageSize,
showSizeChanger: true,
showQuickJumper: true,
total: data.length
}}
onChange={handleChange}
/>
</Col>
</Row>
{
visible && <AddForm handleOk={handleOk} handleCancel={handleCancel} categories={getCategories} />
}
</div>
)
}
/**
* 新增產品Modal
*/
interface FormProps {
handleOk: any,
handleCancel: any,
categories: Array<Category>
}
const AddForm:React.FC<FormProps> = ({handleOk, handleCancel, categories}) => {
let [product, setProduct] = useState<Product>({ name: '', categoryId: [] });
let [addProduct] = useMutation(ADD_PRODUCT);
const handleSubmit = async () => {
// 獲取表單的值
await addProduct({
variables: product,
refetchQueries: [{
query: GET_PRODUCTS
}]
})
// 清空表單
setProduct({ name: '', categoryId: [] })
handleOk()
}
return (
<Modal
title="新增產品"
visible={true}
onOk={handleSubmit}
okText="提交"
cancelText="取消"
onCancel={handleCancel}
maskClosable={false}
>
<Form>
<Form.Item label="商品名稱">
<Input
placeholder="請輸入"
value={product.name}
onChange={event => setProduct({ ...product, name: event.target.value })}
/>
</Form.Item>
<Form.Item label="商品分類">
<Select
placeholder="請選擇"
value={product.categoryId}
onChange={(value: string | []) => setProduct({ ...product, categoryId: value })}
>
{
categories.map((item: Category) => (
<Option key={item.id} value={item.id}>{item.name}</Option>
))
}
</Select>
</Form.Item>
</Form>
</Modal>
)
}
export default ProductList;
開發商品詳情組件ProductDetail:
根據ID查詢商品詳情及其所屬商品分類下的所有商品。
import React from 'react'; import { Card, List } from 'antd'; import { useQuery } from '@apollo/react-hooks'; import { GET_PRODUCT } from '@/api'; import { Product } from '@/types'; const ProductDetail: React.FC = (props:any) => { let _id = props.match.params.id; let { loading, error, data } = useQuery(GET_PRODUCT,{ variables: { id: _id } }); if(error) return <p>加載發生錯誤</p>; if(loading) return <p>加載中...</p>; const { getProduct } = data; const { id, name, category: { id: categoryId, name: categoryName, products }} = getProduct; return ( <div> <Card title="商品詳情" bordered={false} style={{width:'100%'}}> <div> <p><b>商品ID:</b>{id}</p> <p><b>商品名稱:</b>{name}</p> </div> <List header={ <div> <p><b>分類ID:</b>{categoryId}</p> <p><b>分類名稱:</b>{categoryName}</p> </div> } footer={null} bordered dataSource={products} renderItem={(item:Product) => ( <List.Item> <p>{item.name}</p> </List.Item> )} > </List> </Card> </div> ) } export default ProductDetail;
4、圖片展示
商品列表頁

新增商品

刪除商品

商品詳情

