基于 apollo-client + antd + typescript 快速搭建管理后台,具备开发体验舒适、开发效率高、可维护性高等优点。本文用于记录基础,方便日后回顾
简单说,apollo-client 是用于对接 graphql 端的框架。个人认为最大的杀手锏是 apollo 会缓存所有query
获取到的数据并提供状态管理能力,当mutation
修改了某些数据时,只要正确返回该数据所在的type
,所有使用了相关query
的页面都会自动变更,不需要人为进行状态管理。
antd 提供了一套相对完整的 react 组件库,主要用于制作管理后台。兼容主流浏览器以及 IE10+(需要 polyfill)
TypeScript 是 JavaScript 的超集,增加了类型检查以及便捷的语法糖(例如可选链式调用,a?.b?.c
)。个人认为最大的杀手锏在于类型检查,增加了代码的可维护性和减少手误等导致的 bug。配合 graphql 时开发体验更是舒适,根据 schema 生成类型说明文件,对接接口不再需要查看文档,开发过程中编辑器就会自动补全相应字段,省心省力。
npm uninstall -g create-react-app
which create-react-app # eg: /usr/local/bin/create-react-app
rm -rf /usr/local/bin/create-react-app
npx create-react-app my-admin --template typescript
cd my-admin
npm install
执行完毕后,运行
npm run start
,打开 localhost:3000 看到react
标志则初始化成功
npm install tslint tslint-config-prettier tslint-eslint-rules tslint-loader tslint-react tslint-react-hooks --save-dev
在根目录创建 tslint.json,属于以下配置
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended",
"tslint-react",
"tslint-eslint-rules",
"tslint-config-prettier",
"tslint-react-hooks"
],
"rules": {
// 允许使用todo注视
"no-suspicious-comment": false,
// 允许使用console
"no-console": false,
// import不需要按照字母从小到大排序
"ordered-imports": false,
// obj属性不需要按字母从小到大排序
"object-literal-sort-keys": false,
// class不需要显示定义public等
"member-access": false,
// 允许使用<reference path=>
"no-reference": false,
// 不满足tslint-react-hooks规则的标记为错误
"react-hooks-nesting": "error"
}
}
打开 package.json,将 scripts 命令相关改为如下
{
"scripts": {
"lint": "tslint --project \"tsconfig.json\""
}
}
打开config-overrides.js(该文件会在配置antd的环节中创建,此处先穿插),配置如下
const {
override,
disableEsLint,
addTslintLoader
} = require("customize-cra");
module.exports = override(
addTslintLoader(),
disableEsLint()
);
详情查看 supported-browsers
npm install react-app-polyfill --save
// 打开src/index.tsx,增加如下代码
import "react-app-polyfill/ie11";
import "react-app-polyfill/stable";
希望能通过 @pages/xxx.tsx 来访问 src/pages/xxx.tsx 文件
打开 config-overrides.js ,增加如下代码
const { override, addWebpackAlias } = require("customize-cra");
const path = require("path");
module.exports = override(
addWebpackAlias({
["@pages"]: path.resolve(__dirname, "src/pages")
})
);
根目录新建 tsconfig.paths.json
{
"compilerOptions": {
"paths": {
"@pages/*": ["./src/pages/*"]
}
}
}
打开 tsconfig.json,增加如下代码
{
"extends": "./tsconfig.paths.json",
"compilerOptions": {
"baseUrl": "."
}
}
npm install antd dayjs --save
npm install customize-cra react-app-rewired babel-plugin-import --save-dev
打开 package.json,将 scripts 命令相关改为如下
{
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build"
}
}
在根目录添加 config-overrides.js,配置如下
const {
override,
fixBabelImports,
addWebpackPlugin
} = require("customize-cra");
module.exports = override(
/**
* 这个东西用来干嘛的?
*
* 该插件用于将
* import { Button } from 'antd';
* 转化为
* var _button = require('antd/lib/button');
* require('antd/lib/button/style/css');
*
* 所以不用也可以,但需要每次都自行指定组件的路径和引入相应样式
*/
fixBabelImports("import", {
libraryName: "antd",
libraryDirectory: "es",
style: "css"
})
);
打开 src/index.tsx,修改为如下代码
import React from "react";
import ReactDOM from "react-dom";
import zh_CN from "antd/lib/locale-provider/zh_CN";
import { Alert, ConfigProvider } from "antd";
ReactDOM.render(
<ConfigProvider locale={zh_CN}>
<Alert message="Hello word" />
</ConfigProvider>,
document.getElementById("root")
);
npm install react-router-dom --save
npm install @types/react-router-dom --save-dev
创建 src/pages/News.tsx
import React from "react";
const News: React.FC = () => {
return <div>news</div>;
};
export default News;
创建 src/Route.tsx
import React, { Suspense, lazy } from "react";
import { Route, Switch } from "react-router-dom";
import { Skeleton } from "antd";
const News = lazy(() => import("@pages/News.tsx"));
const MyRoute = () => {
return (
<BrowserRouter>
<Suspense fallback={<Skeleton active={true} />}>
<Switch>
<Route path="/news" component={News} />
</Switch>
</Suspense>
</BrowserRouter>
);
};
export default MyRoute;
打开 src/index.tsx,修改如下代码
import MyRoute from "./Route";
ReactDOM.render(
<ConfigProvider locale={zh_CN}>
<Alert message="Hello word" />
<MyRoute />
</ConfigProvider>,
document.getElementById("root")
);
import { Route, Redirect, RouteProps } from "react-router-dom";
import { Result } from "antd";
const AccessDenied = () => (
<Result
status="403"
title="403"
subTitle="您没有权限访问该页面"
extra={null}
/>
);
// eg: <PrivateRoute path={'/news'} component={News} />
const PrivateRoute = ({ ...rest }: RouteProps) => {
/**
* eg: loading中间状
*
* if (loading) return <Skeleton active={true} />
*/
/**
* eg: 判断登录
*
* if (!isLogin) return <Redirect to={{ pathname: '/login' }} />
*/
/**
* eg: 判断权限
*
* if (noPermission) return <Route {...rest} component={AccessDenied} />
*/
return <Route {...rest} />;
};
需要 graphql 服务器,如果没有,请参考apollo-server,以下均以 localhost:4000/graphql 做例子
npm install apollo --save-dev
npm install apollo-boost @apollo/react-hooks graphql-tag --save
// package.json,添加schema命令
{
"scripts": {
"schema": "apollo client:download-schema schema.graphql --endpoint http://localhost:4000/graphql"
}
}
npm run schema
执行成功后,会自动在根目录添加 schema.graphql 文件,可用于开发时参考
新建 src/graphql/client.ts
import ApolloClient from "apollo-boost";
const client = new ApolloClient({
uri: "http://localhost:4000/graphql",
// 添加自定义header,例如authentication token
request: operation => {
// 可以从localstorage等地方获取,每次请求时都会附带,可为异步方法(await)
const token = "xxxx";
operation.setContext({
headers: {
authorization: token
}
});
},
// 统一处理错误返回
onError: ({ graphQLErrors, networkError }) => {
// networkError: 网络错误
// graphQLErrors: 服务端抛出的错误
// 状态码: networkError && 'statusCode' in networkError && networkError.statusCode;
}
});
src/index.tsx, 添加如下代码
import apolloClient from "@graphql/client"; // 需要先配置alias
import { ApolloProvider } from "@apollo/react-hooks";
ReactDOM.render(
<ApolloProvider client={apolloClient}>xxxx</ApolloProvider>,
document.getElementById("root")
);
打开 package.json,添加新的 scripts 命令
{
"scripts": {
// 识别由graphql-tag方法提供的gql方法,根据内容自动在src/__generated__文件夹内生成描述文件,--watch表示开启监听
"codegen": "apollo client:codegen src/__generated__ --target typescript --endpoint http://localhost:4000/graphql --outputFlat --watch"
}
}
假设要将登录人员的 token 保存为全局状态
创建 src/graphql/typeDefs.ts,用于定义 schema
import gql from "graphql-tag";
// 客户端schema
const typeDefs = gql`
# 当前登录的用户
type LoggedUser {
id: Float
token: String!
}
# 设置登录用户需要的参数
input LoggedUserInput {
id: Float
token: String!
}
extend type Query {
# 获取已登录的用户
loggedUser: LoggedUser
}
extend type Mutation {
# 保存用户信息
saveLoggedUser(input: LoggedUserInput!): LoggedUser!
}
`;
export default typeDefs;
创建 src/graphql/resolvers.ts,用于定义 resolvers
import { Resolvers } from "apollo-boost";
const resolvers: Resolvers = {
Mutation: {
saveLoggedUser: (_, variables, { cache }) => {
const loggedUser = { ...variables.input, __typename: "LoggedUser" };
cache.writeData({ data: { loggedUser } });
return loggedUser;
}
}
};
export default resolvers;
打开 src/graphql/client.ts,添加 resolvers 和 typeDefs
import typeDefs from "./typeDefs";
import resolvers from "./resolvers";
const client = new ApolloClient({
// xxx
resolvers,
typeDefs
});
创建 src/graphql/gql.ts,用于定义 grqphql 语句
import gql from "graphql-tag";
// 获取本地的登录人员信息
export const LOGGED_USER = gql`
query LoggedUser {
loggedUser @client {
id
token
}
}
`;
// 将登录信息保存到全局
export const SAVE_LOGGED_USER = gql`
mutation SaveLoggedUser($input: LoggedUserInput!) {
saveLoggedUser(input: $input) @client {
id
token
}
}
`;
保存 gql.ts 后,执行
npm run codegen
,会看到相关类型描述文件已生成在 src/generated文件夹中
获取全局登录人员信息
import { LoggedUser } from "@generated/LoggedUser";
import { useQuery } from "@apollo/react-hooks";
import { LOGGED_USER } from "@graphql/gql";
// LoggedUser是data的类型描述,由npm run codegen生成
const { data, loading } = useQuery<LoggedUser>(LOGGED_USER);
设置全局登录人员信息
import {
SaveLoggedUser,
SaveLoggedUserVariables
} from "@generated/SaveLoggedUser";
import { useMutation } from "@apollo/react-hooks";
import { SAVE_LOGGED_USER } from "@graphql/gql";
// SaveLoggedUser是saveLoggedUser返回结果的类型描述,由npm run codegen生成
// SaveLoggedUserVariables是saveLoggedUser所需要的参数的类型描述,由npm run codegen生成
const [saveLoggedUser] = useMutation<SaveLoggedUser, SaveLoggedUserVariables>(
SAVE_LOGGED_USER
);
await saveLoggedUser({
variables: {
input: {
id: 1,
token: "123"
}
}
});
请求服务器端数据时,操作和请求全局状态类似,都是先添加定义 grqphql 语句,后使用
useQuery
或useMutation
npm install dayjs --save
npm install antd-dayjs-webpack-plugin --save-dev
打开 config-overrides.js,增加如下配置
const { override, addWebpackPlugin } = require("customize-cra");
const AntdDayjsWebpackPlugin = require("antd-dayjs-webpack-plugin");
module.exports = override(
/**
* 这个东西用来干嘛的?
*
* 使用dayjs替换antd默认使用的Moment.js
*/
addWebpackPlugin(new AntdDayjsWebpackPlugin())
);
打开 src/index.tsx,增加如下代码
import "dayjs/locale/zh-cn";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import weekOfYear from "dayjs/plugin/weekOfYear";
import isMoment from "dayjs/plugin/isMoment";
import badMutable from "dayjs/plugin/badMutable";
import localeData from "dayjs/plugin/localeData";
import advancedFormat from "dayjs/plugin/advancedFormat";
import weekYear from "dayjs/plugin/weekYear";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);
dayjs.extend(advancedFormat);
dayjs.extend(customParseFormat);
dayjs.extend(weekYear);
dayjs.extend(weekOfYear);
dayjs.extend(isMoment);
dayjs.extend(localeData);
dayjs.extend(badMutable);
dayjs.locale("zh-cn");
nprogress 用于切换页面时,在浏览器顶部显示过渡动画
npm install nprogress --save
npm install @types/nprogress --save-dev
打开 src/Route.tsx,添加如下代码
import "nprogress/nprogress.css";
import nprogress from "nprogress";
const FancRoute = ({ ...rest }) => {
nprogress.start();
useEffect(() => {
nprogress.done();
});
return <Route {...rest} />;
};
const MyRoute = () => {
return (
<Switch>
<FancRoute path={xxx} component={xxx} />
</Switch>
)
}
官方提供了useLazyQuery
,但存在多次调用问题。暂时可通过以下方式模拟
const [skipQuery, setSkipQuery] = useState(true);
const { data, loading } = useQuery(SOME_QUERY, {
skip: skipQuery
});
// 当想要触发query时
skipQuery(false);
import React from "react";
import { FormComponentProps } from "antd/lib/form";
interface IDemo extends FormComponentProps {
text: string;
}
const Demo: React.FC<IDemo> = ({ form, text }) => {
// xxxx
};
export default Form.create<IDemo>({})(Demo);
某些时候,需要在异步操作后修改组件状态,如果在此之前组件被 unmounted 了,那修改相关状态就会报错
可通过自定义 hook 判断组件是否 mounted
import { useRef, useEffect } from "react";
const useIsMounted = () => {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return isMounted;
};
export default useIsMounted;
const isMounted = useIsMounted();
if (isMounted) {
// 组件还活着,放心修改状态
}
import { useRef, useEffect } from "react";
export default function usePrevious<T>(
value?: T
): [T | undefined, (argValue: T) => void] {
const ref = useRef<T>();
const setCurrent = (argValue: T) => (ref.current = argValue);
useEffect(() => {
ref.current = value;
}, [value]);
return [ref.current, setCurrent];
}
const [preState, setPreState] = usePrevious();