项目目录(主要文件结构)
| - components - 公共组件
|
| - pages
| |
| | - index 首页目录
| | |
| | | - index.page.tsx 特殊命名格式 xxx.page.tsx 会作为路由页面
| |
| | - article 文章详情
| |
| | - index.page.route.ts 特殊命民格式 xxx.page.route.ts 来处理动态路由格式 如/article/:id
|
| - renderer
| |
| | - _default.page.client.tsx 浏览器端渲染
| |
| | - _default.page.server.tsx 服务端渲染
| |
| | - apolloClient.ts apollo-graphql客户端,如果用了其他状态管理,自行替换
|
| - server
| |
| | - index.ts
|
vite.config.ts配置
这里因为我的前端和管理端共用一个域名,所以添加baseServer来区分
import { defineConfig, UserConfig } from 'vite'
import react from '@vitejs/plugin-react'
import ssr from 'vite-plugin-ssr/plugin'
const config: UserConfig = {
publicDir: "./public",
build: {
outDir: 'dist',
},
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true,
modifyVars: {
'@heading-color': '#333333',
'@layout-header-background': '#ffffff',
'@layout-header-padding': '0px',
'@border-color-base': '#e6eaea',
'@primary-color': '#1890ff',
}
}
}
},
plugins: [
react(),
ssr({
baseServer: '/app'
}),
],
}
export default config
tsconfig.json 及 tsconfig.node.json配置
因为使用ts编写整个项目,包括server端
所以需要额外配置ts-node相关配置,主要是要让其支持esm
// tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"outDir": "./dist"
},
"ts-node": {
"esm": true,
"compilerOptions": {
"module": "ES2022",
"types": ["vite/client"],
}
},
"include": ["./", "vite-env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
// tsconfig.node.json 这个是默认的
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
package.json
ts-node 坑点有两个
- 需要设置"type": "module"字段
- 不能直接ts-node ./server/index.ts 需要改用下面的方式
- 生产环境pm2启动ts-node也有坑,这里我将npm run windows的内容扔进run-ts.sh里,然后在生产环境服务器上使用pm2启动
{
"name": "vite-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "npm run server",
"prod": "npm run build && npm run server:prod",
"build": "vite build",
"server": "node --loader ts-node/esm ./server/index.ts --files",
"windows": "cross-env NODE_ENV=production ts-node ./server/index.ts",
"start": "cross-env NODE_ENV=production pm2 start run-ts.sh --name app",
"stop": "pm2 stop app",
"codegen": "graphql-codegen --config ./codegen.yaml"
},
"dependencies": {
"@apollo/client": "^3.7.9",
"antd": "^5.2.2",
"compression": "^1.7.4",
"cross-fetch": "^3.1.5",
"express": "^4.18.2",
"pm2": "^5.2.2",
"moment": "^2.29.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ts-node": "^10.9.1",
"vite-plugin-ssr": "^0.4.87"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.2.0",
"@graphql-codegen/fragment-matcher": "3.1.0",
"@graphql-codegen/introspection": "^2.1.0",
"@graphql-codegen/near-operation-file-preset": "^2.1.4",
"@graphql-codegen/typescript": "^2.2.2",
"@graphql-codegen/typescript-graphql-files-modules": "2.1.0",
"@graphql-codegen/typescript-operations": "^2.1.4",
"@graphql-codegen/typescript-react-apollo": "^3.1.4",
"@types/compression": "^1.7.2",
"@types/express": "^4.17.14",
"@types/graphql": "^14.5.0",
"@types/node": "^18.14.6",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react": "^3.1.0",
"cross-env": "^7.0.3",
"less": "^4.1.3",
"tslib": "^2.5.0",
"typescript": "^4.9.3",
"vite": "^4.1.0",
"vite-plugin-imp": "^2.3.1"
}
}
主要数据流向梳理
大体流向
server.ts -> _default.page.server.tsx -> _default.page.client.tsx -> xxx.page.tsx
- server.ts 传递 pageContextInit
import express from 'express';
import { renderPage } from 'vite-plugin-ssr'
import { createServer } from 'vite';
import { dirname } from 'path'
import { fileURLToPath } from 'url'
// 这里是因为ts-node报错!所以加了.js。 这个函数可以类比成 redux创建store
import makeApolloClient from '../renderer/apolloClient.js';
const isProduction = process.env.NODE_ENV === 'production'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = `${__dirname}/..`;
async function startServer() {
const base = '/app';
const app = express();
if (isProduction) {
app.use(express.static(`${root}/dist/client`))
} else {
const viteDevMiddleware = (
await createServer({
base,
root,
server: { middlewareMode: true }
})
).middlewares
app.use(viteDevMiddleware)
}
app.get('*', async (req, res, next) => {
const apolloClient = makeApolloClient({
ssrMode: true
});
const pageContextInit = {
urlOriginal: req.originalUrl,
client: apolloClient,
dev: !isProduction
}
const pageContext = await renderPage(pageContextInit)
const { httpResponse } = pageContext
if (!httpResponse) return next()
const { body, statusCode, contentType } = httpResponse
res.status(statusCode).type(contentType).send(body)
})
const port = 4000
app.listen(port)
console.log(`Server running at http://localhost:${port}${base}`)
}
startServer()
-
_default.page.server.tsx 接受 pageContextInit 组装 html,且传递 pageContext 给_default.page.client.tsx
传递 pageContext 这里有坑点
-
必须要在passToClient显示的指明要传递的key
-
value必须能序列化
-
// _default.page.server.tsx
import React from 'react'
import { escapeInject, dangerouslySkipEscape } from 'vite-plugin-ssr'
import { renderToStringWithData } from '@apollo/client/react/ssr/index.js'
import App from './app'
import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs';
import './default.less';
import { PageContextServer } from './types';
import { PageContextProvider } from './useContext';
import { gql } from '@apollo/client/index.js';
import { config } from './config.js';
import fetch from 'cross-fetch';
const passToClient = [
'apolloIntialState',
'routeParams',
'is404',
'DEV',
'SSR',
]
async function render(pageContext: PageContextServer) {
const cache = createCache();
const { Page, client } = pageContext;
const styleText = extractStyle(cache);
const tree = (
<App client={client}>
<PageContextProvider pageContext={pageContext}>
<StyleProvider cache={cache}>
<Page />
</StyleProvider>
</PageContextProvider>
</App>
)
const pageHtml = await renderToStringWithData(tree);
const documentHtml = escapeInject`<!DOCTYPE html>
<html>
<head>
<title>wilsonwang</title>
<style>${styleText}</style>
</head>
<body>
<div id="root">${dangerouslySkipEscape(pageHtml)}</div>
</body>
</html>`;
const { DEV, SSR } = import.meta.env;
return {
documentHtml,
pageContext: {
DEV,
SSR,
}
}
}
// 初次加载页面直接走服务器请求
async function onBeforeRender(pageContext: PageContextServer) {
const { client, exports } = pageContext;
let api: string;
const { DEV } = import.meta.env;
if (DEV) {
api = config.dev.api;
} else {
api = config.product.api;
}
// 这里是页面组件自定义的请求方法和参数等
if (exports.query) {
const data = await fetch(api, {
method: 'POST',
body: JSON.stringify(exports.query),
headers: {
'content-type': 'application/json',
}
})
const result = await data.json();
if (Array.isArray(result)) {
result.forEach((r, i) => {
const request = exports.query[i];
const cacheVariables = request.cacheVariables;
// 处理apollo缓存key,不要携带variables问题
const variables = cacheVariables ? request.variables : undefined;
const query = gql(request.query);
client.cache.writeQuery({
query: query,
data: r.data,
variables: variables,
})
})
}
}
const apolloIntialState = client.extract();
return {
pageContext: {
apolloIntialState,
}
}
}
export {
passToClient,
render,
onBeforeRender
}
- _default.page.client.tsx 再传递 pageContext给 xxx.page.tsx
这个文件就比较简单了
// _default.page.client.tsx
import { hydrateRoot } from 'react-dom/client'
import App from './app'
import makeApolloClient from './apolloClient';
import './default.less';
import { PageContextClient } from './types';
import { PageContextProvider } from './useContext';
import { Page as ErrorPage } from './_error.page';
function render(pageContext: PageContextClient) {
const { Page, ...pageContextOmitPage } = pageContext;
const { apolloIntialState, is404, SSR, DEV } = pageContextOmitPage;
const apolloClient = makeApolloClient({
apolloIntialState,
ssrMode: SSR,
dev: DEV
});
const root = document.getElementById('root');
if (root) {
const renderDom = is404 ? <ErrorPage /> : <Page {...pageContextOmitPage} />
hydrateRoot(
root,
<App client={apolloClient}>
<PageContextProvider pageContext={pageContextOmitPage}>
{ renderDom }
</PageContextProvider>
</App>
)
}
}
export { render }
- 集成apollo客户端
// apolloClient.ts
import { ApolloClient, InMemoryCache, ApolloLink, from, NormalizedCacheObject } from "@apollo/client/index.js";
import { BatchHttpLink } from "@apollo/client/link/batch-http/index.js";
import { RetryLink } from "@apollo/client/link/retry/index.js";
import { onError } from "@apollo/client/link/error/index.js";
import { config } from './config.js';
import fetch from 'cross-fetch';
interface MakeApolloClientArgs {
apolloIntialState?: NormalizedCacheObject;
ssrMode?: boolean;
dev?: boolean;
}
const makeApolloClient = (makeApolloClientArgs: MakeApolloClientArgs) => {
const { apolloIntialState, ssrMode, dev } = makeApolloClientArgs
let uri: string;
if (dev) {
uri = config.dev.api;
} else {
uri = config.product.api;
}
// 合并请求
const link = new BatchHttpLink({
uri,
batchMax: 5,
batchInterval: 20,
includeExtensions: true,
fetch: fetch,
})
const INVALID_TOKEN = 'Response not successful: Received status code 401';
// 网络错误重试请求
const retryLink = new RetryLink({
delay: {
initial: 300,
max: Infinity,
jitter: true
},
attempts: {
max: 5,
retryIf: (error, _operation) => {
if (error && error.message != INVALID_TOKEN) {
return !!error
}
return false;
}
}
});
// 请求拦截器
const beforeRequest = new ApolloLink((operation, forward) => {
operation.setContext(({ headers = {} }) => ({
headers: {
...headers,
},
http: {
includeExtensions: true,
includeQuery: true,
}
}));
return forward(operation)
});
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
articleList: {
keyArgs: false,
}
}
}
}
})
// 如果有服务端请求好的数据,直接塞进store里面
if (apolloIntialState) {
cache.restore(apolloIntialState);
}
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
const {message, locations, path, extensions } = err;
const response: any = extensions?.response;
const statusCode: number = response?.statusCode;
switch (statusCode) {
case 403:
console.error(403)
return;
}
const msgError = `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`;
console.error(msgError)
}
}
if (networkError) {
if (INVALID_TOKEN == networkError.message) {
console.log('请重新登录')
return;
}
}
});
return new ApolloClient({
name: 'app',
version: '0.0.1',
uri,
cache,
queryDeduplication: true,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-first',
},
},
ssrMode,
link: from([
beforeRequest,
retryLink,
errorLink,
link,
]),
})
}
export default makeApolloClient;
最后一步页面使用
因为 apollo 的状态管理,为前端添加了缓存层,所以我们直接调用接口,会优先从store里面匹配,有的话直接返回数据,没有的话才真正走network。
// xxx.page.tsx
import { FC, useRef, useEffect } from "react";
import { Article, Tag as ITag, useArticleListLazyQuery, TagListDocument, useTagListQuery, ArticleListDocument, useArticleListQuery, ArticleListQueryVariables } from '../../generated';
import { Row, Spin, Tag } from "antd";
import { useReactiveVar } from "@apollo/client/index.js";
import { cachePage, cacheTag } from "./cache";
import ArticleItem from "./components/articleItem";
import styles from './index.module.less';
import { LeftCircleOutlined, RightCircleOutlined } from "@ant-design/icons";
import { resolveDocument } from "../../utils/gqlDocument";
const Page: FC = () => {
const page = useReactiveVar(cachePage);
const tagId = useReactiveVar(cacheTag);
const { data: tagData } = useTagListQuery();
const { data, loading, variables, fetchMore } = useArticleListQuery({
variables: {
listForm: { pageNo: page.pageNo, pageSize: page.pageSize, tagId: tagId }
},
onCompleted (result) {
cachePage({
pageNo: result.articleList.pageNo,
pageSize: result.articleList.pageSize,
})
}
})
const fetchList = (variables: ArticleListQueryVariables) => {
fetchMore({
variables,
})
}
const onChangeCheckableTag = (checked: boolean, tag: Partial<ITag>) => {
const selectData = checked ? tag : null;
cacheTag(selectData?.id);
fetchList({
listForm: { pageNo: 1, pageSize: 4, tagId: selectData?.id }
})
}
const totalPage = Math.ceil(Number(data?.articleList.total) / Number(data?.articleList.pageSize));
return (
<div>
<Row justify="center" gutter={24} className={styles.tag}>
{
tagData?.tagList.map(tag => {
const checked = tag.id == tagId;
return (
<Tag.CheckableTag
key={tag.id}
checked={checked}
onChange={checked => onChangeCheckableTag(checked, tag)}
>{ tag.name }</Tag.CheckableTag>
)
})
}
</Row>
<Spin spinning={loading}>
<div>
{
data?.articleList.content.map((article, index) => {
return (
<div key={index}>
<ArticleItem data={article as Article} />
</div>
)
})
}
<Row align='middle' justify='center' className={styles.pagination}>
{
Number(data?.articleList.pageNo) == 1 ? null : (
<div
className={styles.leftArrow}
onClick={() => fetchList({
listForm: {
pageNo: Number(variables?.listForm.pageNo) - 1,
pageSize: Number(variables?.listForm.pageSize),
tagId: variables?.listForm.tagId,
}
})}
>
<LeftCircleOutlined style={{ fontSize: 28 }} />
</div>
)
}
<Row justify='center'>
<div>第{page.pageNo}页</div>
<div>共{totalPage || 0}页</div>
</Row>
{
totalPage === Number(data?.articleList.pageNo) ? null : (
<div
className={styles.rightArrow}
onClick={() => fetchList({
listForm: {
pageNo: Number(variables?.listForm.pageNo) + 1,
pageSize: Number(variables?.listForm.pageSize),
tagId: variables?.listForm.tagId,
}
})}
>
<RightCircleOutlined style={{ fontSize: 28 }} />
</div>
)
}
</Row>
</div>
</Spin>
</div>
)
}
// 这里把页面里需要请求的参数都扔给server
// resolveDocument很简单,只做了一件事,解析gql文档,拆分成server端请求时我们要使用的数据
const query = resolveDocument([
{
documentNode: TagListDocument,
cacheVariables: false
},
{
documentNode: ArticleListDocument,
variables: {
listForm: {
pageNo: 1,
pageSize: 4,
}
},
cacheVariables: false,
}
])
export { Page, query };
配置apollo-codegen
作为一个前端,不想手动把接口文档转换成ts文件
添加codegen.yaml文件
schema: http://localhost:3000/graphql
documents: '**/*.gql'
generates:
./generated/index.tsx:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
config:
withHooks: true
使用 - 我们只要在编写 xxx.gql文件即可
运行npm run codegen 将在./generated/index.tsx里生成hooks函数
如 useArticleListQuery 及 所有类型文件(包含字段注释)
比如上面 示例页面的 两个请求
// schema.gql
query articleList ($listForm: ListForm!) {
articleList (listForm: $listForm) {
pageNo,
pageSize,
total,
content {
title,
description,
id,
createTime
tagList {
name,
color,
id,
}
}
}
}
query tagList {
tagList {
id,
name,
}
}