react使用vite-plugin-ssr

1,097 阅读4分钟

项目目录(主要文件结构)

| - 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 坑点有两个

  1. 需要设置"type": "module"字段
  2. 不能直接ts-node ./server/index.ts 需要改用下面的方式
  3. 生产环境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

  1. 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()
  1. _default.page.server.tsx 接受 pageContextInit 组装 html,且传递 pageContext 给_default.page.client.tsx

    传递 pageContext 这里有坑点

    1. 必须要在passToClient显示的指明要传递的key

    2. 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
}  
  1. _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 }
  1. 集成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>
              &nbsp;&nbsp;
              <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,
  }
}
    

最后还是贴一下github代码