跳到主要内容

vite-react-ts

2023年12月21日
柏拉文
越努力,越幸运

一、前置知识


1.1 构建产物

首先 Vite 作为一个构建工具,是如何支持 SSR 构建的呢?换句话说,它是如何让前端的代码也能顺利在 Node.js 中成功跑起来的呢?

可以分为两种情况:

  • 开发环境下: Vite 依然秉承 ESM 模块按需加载即 no-bundle 的理念,对外提供了ssrLoadModule API,你可以无需打包项目,将入口文件的路径传入 ssrLoadModule 即可

    // 加载服务端入口模块
    const xxx = await vite.ssrLoadModule('/src/entry-server.tsx')
  • 生产环境: Vite 会默认进行打包,对于 SSR 构建输出 CommonJS 格式的产物。我们可以在package.json中加入这样类似的构建指令:

    {
    "build:ssr": "vite build --ssr 服务端入口路径"
    }

    这样 Vite 会专门为 SSR 打包出一份构建产物。因此你可以看到,大部分 SSR 构建时的事情,Vite 已经帮我们提供了开箱即用的方案,我们后续直接使用即可。

1.2 加载入口

Sever 的第一步是 加载服务端入口模块, 主要逻辑如下:

export async function loadSsrEntryModule(
vite: ViteDevServer | null,
isProd,
root
) {
if (isProd) {
// 生产模式下直接 import 打包后的产物
const entryPath = path.join(root, 'dist/server/entry-server.js');
return await import(entryPath);
} else {
// 开发环境下通过 no-bundle 方式加载
const entryPath = path.join(root, 'src/entry-server.tsx');
return vite!.ssrLoadModule(entryPath);
}
}

1.3 组件渲染为字符串(脱水)

调用前端框架的 renderToStringAPI 将组件渲染为字符串

const appHtml = renderToString(
React.createElement(ServerEntry, { data })
);

1.4 服务端向客户端注入数据

在拼接 HTML 的逻辑中,除了添加页面的具体内容,同时我们也注入了一个挂载全局数据的 script 标签。为了激活页面的交互功能,我们需要执行 CSRJavaScript 代码来进行 hydrate 操作,而客户端 hydrate 的时候需要和服务端同步预取后的数据,保证页面渲染的结果和服务端渲染一致,因此,我们刚刚注入的数据 script 标签便派上用场了。由于全局的 window 上挂载服务端预取的数据

1.5 客户端渲染、附加交互行为(注水)

import App from './App'
import { hydrateRoot } from 'react-dom/client';

const data = window.__SSR_DATA__;
const container = document.getElementById('root');
hydrateRoot(container!, <App data={data}/>);

二、交互同构


2.1 工作流

对于 SSR 的运行时,一般可以拆分为比较固定的生命周期阶段,简单来讲可以整理为以下几个核心的阶段:

Preview
  1. 加载 SSR 入口模块: 在这个阶段,我们需要确定 SSR 构建产物的入口,即组件的入口在哪里,并加载对应的模块

  2. 进行数据预取: 这时候 Node 侧会通过查询数据库或者网络请求来获取应用所需的数据

  3. 渲染组件: 这个阶段为 SSR 的核心,主要将第 1 步中加载的组件渲染成 HTML 字符串或者 Stream

  4. HTML 拼接: 在组件渲染完成之后,我们需要拼接完整的 HTML 字符串,并将其作为响应返回给浏览器

从上面的分析中你可以发现,SSR 其实是构建和运行时互相配合才能实现的,也就是说,仅靠构建工具是不够的

2.2 /src/ssr-server/index.ts

import fs from 'fs';
import React from 'react';
import express from 'express';
import serverStatic from 'serve-static';
import { renderToString } from 'react-dom/server';
import { createServer as createViteServer } from 'vite';
import {
fetchData,
loadSsrEntryModule,
matchPageUrl,
resolve,
resolveTemplatePath
} from './util';

const root = resolve('../', '../');
const isProd = process.env.NODE_ENV === 'production';

async function ssrSeverMiddleware(vite) {
return async (req, res, next) => {
try {
// 1. 加载服务端入口模块
const url = req.originalUrl;

if (!matchPageUrl(url)) {
// 走静态资源的处理
return await next();
}

const { ServerEntry } = await loadSsrEntryModule(vite, isProd, root);
// 2. 数据预取
const data = await fetchData();
// 3. 组件渲染 -> 字符串
const appHtml = renderToString(
React.createElement(ServerEntry, { data })
);
// 4. 拼接完整 HTML 字符串,返回客户端
const templatePath = resolveTemplatePath(isProd, root);
let template = await fs.readFileSync(templatePath, 'utf-8');

// 开发模式下需要注入 HMR、环境变量相关的代码,因此需要调用 vite.transformIndexHtml
if (!isProd && vite) {
template = await vite.transformIndexHtml(url, template);
}
const html = template
.replace('<!-- SSR_APP -->', appHtml)
.replace(
'<!-- SSR_DATA -->',
`<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>`
);

res.status(200).setHeader('Content-Type', 'text/html').end(html);

} catch (error) {
vite.ssrFixStacktrace(error);
next(error);
}
};
}

async function createServer() {
const app = express();

/**
* @description: 以中间件模式创建 Vite 应用
* 1. appType: 'custom' 表示禁用 Vite 自身的 HTML 服务逻辑, 并让上级服务器接管控制
* 2. server: { middlewareMode: true }
*/
const vite = await createViteServer({
appType: 'custom',
server: { middlewareMode: true }
});

/**
* @description: 使用 vite 的 Connect 实例作为中间件
*/
app.use((req, res, next) => {
vite.middlewares.handle(req, res, next);
});

app.use('*', await ssrSeverMiddleware(vite));

// 注册中间件,生产环境端处理客户端资源
if (isProd) {
app.use(serverStatic(resolve(root, 'dist/client')));
}

app.listen(3000, () => {
console.log('server is running at http://localhost:3000');
});
}

createServer();

2.3 /src/ssr-server/util.ts

import path from 'path';
import { fileURLToPath } from 'url';
import { ViteDevServer } from 'vite';

export function getProjectRootDir() {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
return __dirname;
}

export function resolve(...pathArray) {
const __dirname = getProjectRootDir();
return path.resolve(__dirname, ...pathArray);
}

export function resolveTemplatePath(isProd, root) {
return isProd
? path.join(root, 'dist/client/index.html')
: path.join(root, 'index.html');
}

export async function loadSsrEntryModule(
vite: ViteDevServer | null,
isProd,
root
) {
if (isProd) {
// 生产模式下直接 require 打包后的产物
const entryPath = path.join(root, 'dist/server/entry-server.js');
return await import(entryPath);
} else {
// 开发环境下通过 no-bundle 方式加载
const entryPath = path.join(root, 'src/entry-server.tsx');
return vite!.ssrLoadModule(entryPath);
}
}

export async function fetchData() {
return { user: 'xxx' };
}

export function matchPageUrl(url: string) {
if (url === '/') {
return true;
}
return false;
}

2.4 /src/entry-client.tsx

import App from './App'
import { hydrateRoot } from 'react-dom/client';

const data = window.__SSR_DATA__;
const container = document.getElementById('root');
hydrateRoot(container!, <App data={data}/>);

2.5 /src/entry-server.tsx

import App from "./App";

function ServerEntry(_props: any) {
return (
<App/>
);
}

export { ServerEntry };

2.6 /src/App.tsx

type AppProps = {
data?: { [key: string]: string};
}

function App(props: AppProps) {
const { data } = props;

const handleClick = ()=>{
console.log("data", data);
}

return (
<div>
<h3>Hello World</h3>
<div onClick={ handleClick }>打开新世界</div>
</div>
);
}

export default App;

2.7 /package.json

{
"name": "vite-react-ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build:client": "vite build --outDir dist/client",
"build": "npm run build:client && npm run build:server",
"preview": "NODE_ENV=production esno src/ssr-server/index.ts",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
"dev": "nodemon --watch src/ssr-server --exec 'esno src/ssr-server/index.ts'"
},
"dependencies": {
"@types/express": "^4.17.21",
"@types/koa": "^2.13.12",
"esno": "^4.0.0",
"express": "^4.18.2",
"koa": "^2.14.2",
"nodemon": "^3.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"serve-static": "^1.15.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}