介绍
之前与掘友分享过一篇 关于 #从零构建自己的SSR同构框架... 的这篇文章 对基于 NestJS 构建SSR 的方式只进行了简单的介绍里面还有许多的不足的地方,这篇文章是用来补充和 填坑的,后续我会不断的完善。 同样的 首先声明 本文的内容 参考和引用了部分 Newegg(Newegg.com) 的设计 若有侵权请联系
先简单的介绍一下 功能, 在 Newegg 我们使用 NodeJS(Nestjs) + React 来支撑 整个 Newegg.com 站点的前端。
- 基于NestJs 实现BFF。(在Nest我们会组装各个Service 返回的数据,然后做业务)
- 基于 NestJS 提供的API 我们把React 的SSR 整合了进去。
- 多Img 部署,比如HomeModule 是独立的一个Img 只管Home相关的内容,NasModule 也是一个独立的Img,再结合Newegg的K8s 基础设施,对流量和并发融灾什么的完全可以照顾周全。
本文章提供了一个简易的实现,它支持了以上的四个功能 (由于多Img部署需要docker集群我就没有往下更了,日后补上,不过我想 文章列出的内容 ,你已经完全知道接下来怎么做了 对吗?)
实现
首先我们改造之前的文章提到的代码
主要是目标就是把 vite 的全丢掉,基础的SSR 保留着除外
- 我们需要移除所有的vite相关的东西
(为什么需要移除呢?主要是自己用不来还没有研究透)
~ main.ts
// remove vite相关的代码
// Vite 中间,为了能在其他的ctx 访问 , viteServer 实例
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
app.use((req, res, next) => {
req['viteServer'] = vite;
next();
});
app.use(vite.middlewares);
~ RenderInterceptor.tsx
// 移除pipRender 与vite相关的内容
// 读取html
let template = '';
if (process.env.NODE_ENV_ === 'production') {
template = readFileSync(
resolve(__dirname, '../../../client', 'index.html'),
'utf-8',
);
} else {
template = readFileSync(
resolve(__dirname, '../../../', 'index.html'),
'utf-8',
);
}
// 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
template = await vs.transformIndexHtml(req.originalUrl, template);
// 得到一段ssr str
const appHtml = render(options.page, initData);
const html = template.replace(`<!--ssr-outlet-->`, appHtml);
// 返回
return html;
~ InitStateContext
// 把这个hooks 先删除吧,我们不要了
// 当然就是 删除之后 要把 render/client.tsx render/server.tsx 里的引用都更新了
重写SSR
我们这次新的思路如下图, 大概的意思是 NestJS 会处理SSR 和BFF
- 聚焦如何SSR
// RenderReact.ts 改造更符合 Nest 的API规范
import { SetMetadata } from '@nestjs/common';
export const RenderReact = (pageContent: any) =>
SetMetadata('ReactComponent', pageContent);
// RenderInterceptor.ts,( 如果下面的代码不理解请参考 Nest
// 我的文章 https://juejin.cn/post/7230012114600886309#heading-4
++++
const PageReactContent = this.reflector.get<any>(
'ReactComponent',
context.getHandler(),
);
++++
private pipRender = (options: InterPipRender) => {
return async (initData: any) => {
initData.page = options.path;
const appHtml = render(options.page, initData.initState);
const html = this._HTML(
appHtml
);
return html;
};
};
// 我们不需要使用固定的模板index.html 我们直接用string
private _HTML(
reactContentString: string,
) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="root">${reactContentString}</div>
<script>
window.__INIT_STATE__ = ${JSON.stringify(data)};
</script>
</body>
</html>
`;
}
以上 SSR 的就完成了,下一步我们考虑一下 资源文件如何处理
- 聚焦Assets资源的处理
经过上面的改造之后 我们仅拿到了SSR的内容,现在我们需要处理资源 我们新建一个static 目录里面存放我们的资源文件,
我们还需要对Nest的构建规则做修改,让它把static 放到dist下,这样方便后续的维护和docker构建
~ nest-cli.json
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"webpack": false,
"tsConfigPath": "./tsconfig.json",
"assets": [
{
"include": "../static/**/*",
"outDir": "./dist/static",
"watchAssets": true
}
]
}
}
这样构建的时候文件夹就能cv到dist 下了
然后我们需要 nest 开放这个 static 文件路径
+++
app.useStaticAssets(join(__dirname, '../../../', 'static'), {
prefix: '/static',
});
好现在我们都能够自由的访问到资源了 下一步,我们设计一下 如何让pageModule 引入这些资源, 在Newegg 我们都是以Module一个单元考虑的 比如homeModule 中就包含了HomeModule 需要的各种各样的Page 和资源,以及SEO, 所以我们直接把逻辑交给 BFF处理
~ homeController.ts
private buildLinks() {
return [ // 以后这些东西 可以放到配置和CDN 上
'/static/css/layout/layout.css',
'/static/css/components/button.css',
'/static/css/pages/home.css',
];
}
@Get('')
@RenderReact(Home)
home() {
return {
initState: { // 这才是 initState
value: 'What is 666 ?',
valuvlaue: {
xrrr: {
a: 1,
},
},
},
pageInfo: { // SEO放这里
title: 'title - test',
description: 'description - test',
},
links: this.buildLinks(),
};
}
然后我们在SSR 的时候把这些加上去
~ render.interceptor.ts
// 先改 _HTML
private _HTML(
reactContentString: string,
data?: string, // initState
SEO?: string, // SEO 信息
links?: string, // Style 相关的
injectJS?: string, // hydrate 的路径
) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${SEO || ''}
${links || ''}
</head>
<body>
<div id="root">${reactContentString}</div>
<script>
window.__INIT_STATE__ = ${JSON.stringify(data)};
</script>
</body>
${injectJS || ''}
</html>
`;
}
private injectStyle() {
return (value) => {
const links = value.links.map((item) => `.` + item) as string[];
let linkStr = '';
links.forEach((link) => {
linkStr += `<link rel="stylesheet" href="${link}">`;
});
value.links = linkStr;
return value;
};
}
private injectJS(name: string) {
return (value) => {
value.injectJS = `<script src="/webSource/scripts/${name}.js"></script>`;
return value;
};
}
private injectSEO() {
return (value) => {
value.SEO = `
<title>${value.pageInfo.title}</title>
<description>${value.pageInfo.description}</description>
`;
return value;
};
}
intercept(context: ExecutionContext, next: CallHandler): Observable<string> {
const [req] = context.getArgs<[Request, Response]>();
const name = context.getClass<any>().name.replace('Controller', '');
// 注意 我们约定一个规范 render 的名称要和 客户端的js 保存一致!
const PageReactContent = this.reflector.get<any>(
'ReactComponent',
context.getHandler(),
);
return next.handle().pipe(
map(this.injectStyle()),
map(this.injectJS(name.toLocaleLowerCase())),
map(this.injectSEO()),
map(async (value) => {
return this.pipRender({
path: req.path,
page: PageReactContent,
})(value);
}),
from,
);
}
// ~ server.tsx SSR渲染我们还是放到同一个文件中进行初始化
import { renderToString } from 'react-dom/server';
const App = (props) => {
return <>{props.children}</>;
};
const render = (PageContent, initState) => {
return renderToString(
<App>
<PageContent initState={initState}></PageContent>
</App>,
);
};
export { render };
// ~ 其他的Client 我们不做统一处理 而是选择把它丢到 *.client.tsx 下 这样我们才能在webpack的构建clientjs的时候 指定入口 及多入口
│ ├── controller
│ │ └── home.controller.ts
│ ├── home.client.tsx
│ ├── home.module.ts
│ ├── home.view.tsx
│ └── service
│ └── home.service.ts
import { hydrateRoot, createRoot } from 'react-dom/client';
import Home from './home.view';
const get_initState = () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return window.__INIT_STATE__;
};
const App = (props) => {
return <>{props.children}</>;
};
hydrateRoot(
document.getElementById('root'),
<App>
<Home initState={get_initState()}></Home>
</App>,
);
重写hydrateJS 的构建方式
下面是重点 我们使用 webpack 去构建 client.js
tools 下设置
- webpack.base.js
- webpack.prod.js
- webpack.dev.js
// ~ base
// webpack.base.js
const path = require('path');
const { globSync } = require('glob');
const LicenseWebpackPlugin = require('webpack-license-plugin');
const getAllClientFiles = () => {
const matchFiles = globSync(
path.join(__dirname, '../src/modules/**/*.client.tsx'),
{
ignore: 'node_modules/**',
},
);
const filePathWithRoutes = {};
matchFiles.forEach((filePath) => {
const mapName = filePath.split('/').pop().split('.').shift();
filePathWithRoutes[mapName] = filePath;
});
return filePathWithRoutes;
};
module.exports = {
entry: getAllClientFiles(),
output: {
path: path.join(__dirname, '../dist'),
filename: 'webSource/scripts/[name].js',
clean: false,
publicPath: '/webSource/',
},
module: {
rules: [
{
test: /.(ts|tsx)$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-typescript'],
},
},
},
],
},
plugins: [
new LicenseWebpackPlugin({
outputFilename: 'licenses.txt',
perChunkOutput: false,
}),
],
resolve: {
extensions: ['.js', '.tsx', '.ts'],
alias: {
'@': path.resolve(__dirname, '../src'),
},
},
};
// ~ dev
// webpack.dev.js
const path = require('path');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.js');
// 合并公共配置,并添加开发环境配置
module.exports = merge(baseConfig, {
mode: 'development', // 开发模式,打包更加快速,省了代码优化步骤
devtool: 'eval-cheap-module-source-map',
});
// ~ prod
// webpack.prod.js
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.js');
module.exports = merge(baseConfig, {
mode: 'production', // 生产模式,会开启tree-shaking和压缩代码,以及其他优化
});
好这样运行一下 效果是ok的
"client:dev": "yarn webpack -c tools/webpack.dev.js"
之后我们把这个路径也让nest 去承载
app.useStaticAssets(join(__dirname, '../../../', 'webSource'), {
prefix: '/webSource',
});
app.useStaticAssets(join(__dirname, '../../../', 'static'), {
prefix: '/static',
});
为了能够让client 的东西自动构建 我们使用了 gulp 为什么不使用HRM和webpackService 呢很大原因的 它与Nestjs的结合不好弄
// ~ /tools/gulp/dev.client.js
const { watch } = require('gulp');
const { spawn } = require('child_process');
function defaultTask(done) {
watch('./src/**/*', { ignoreInitial: false }, (done) => {
const child = spawn(
'yarn',
['webpack', '-c', 'tools/webpack.dev.js', '--color'],
{
stdio: 'pipe',
cwd: process.cwd(),
},
);
child.stdout.pipe(process.stdout);
child.stdout.on('data', (data) => {});
done();
});
}
module.exports = {
defaultTask,
};
// ~ /gulpfile.js
const { watch } = require('gulp');
const { defaultTask } = require('./tools/gulp/dev.client.js');
exports.default = defaultTask;
+++
"client:dev": "yarn gulp",
"client:build": "yarn webpack -c tools/webpack.prod.js"
重新设计 启动方式,让多Img构建更简单
我参考了Neweg 的设计,也写了这样的缩水的版本, 具体就是 common的Module 和 业务的module 全部分开
.
├── core
│ ├── commonService // 只管Service相关的
│ │ ├── bootstrap.ts
│ │ ├── index.ts
│ │ ├── ssr // 除外
│ │ │ ├── render
│ │ │ │ ├── render.decorator.ts
│ │ │ │ ├── render.interceptor.ts
│ │ │ │ └── server.tsx
│ │ │ └── utils
│ │ │ └── index.ts
│ │ └── types
│ │ └── index.ts
│ ├── commonUI // 只管UI相关的
│ │ ├── components
│ │ │ ├── Button.tsx
│ │ │ └── Link.tsx
│ │ └── index.ts
│ └── index.ts
├── main.ts
└── modules
├── Home
│ ├── controller
│ │ └── home.controller.ts
│ ├── home.client.tsx // client的构建入口
│ ├── home.module.ts
│ ├── home.view.tsx // SSR Client 共用的一段React 组件
│ └── service
│ └── home.service.ts
└── Nas
├── controller
│ └── nas.controller.ts
├── nas.client.tsx
├── nas.module.ts
└── nas.view.tsx
~ main
import { BootstrapModuleFactory, bootstrap } from './core';
import { HomeModule } from './modules/Home/home.module';
import { NasModule } from './modules/Nas/nas.module';
// 这里就为多img构建提供了非常便捷的调用 方式
const bootstrapModule = BootstrapModuleFactory.create([HomeModule, NasModule]);
bootstrap(bootstrapModule, {
rootDir: __dirname,
});
~ bootstrap.ts
import {
DynamicModule,
Global,
Module,
OnModuleInit,
Type,
} from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { castArray } from 'lodash';
import { ExceptionFilter } from '@nestjs/common';
import { routers, InterRouter } from '../../../conf/router';
import { join } from 'path';
export class BootstrapModuleFactory {
public static create(_module: Type<any> | Array<Type<any>>): DynamicModule {
return {
module: BootstrapModule,
imports: [
ConfigModule.forRoot({
load: [],
}) as any,
...castArray(_module),
],
};
}
}
// 公用的Module(任何img都需要
@Global()
@Module({
providers: [],
controllers: [],
exports: [ConfigModule],
})
class BootstrapModule implements OnModuleInit {
onModuleInit() {
console.log('22');
}
}
export interface IBootstrapOptions {
rootDir?: string;
globalFilters?: ExceptionFilter[];
}
async function bootstrap(module: DynamicModule, options: IBootstrapOptions) {
const app = await NestFactory.create<NestExpressApplication>(module);
app.useStaticAssets(join(__dirname, '../../../', 'webSource'), {
prefix: '/webSource',
});
app.useStaticAssets(join(__dirname, '../../../', 'static'), {
prefix: '/static',
});
await app.listen(3000);
}
export { bootstrap };
参考与引用
本项目Github 🧐 github.com/BM-laoli/no…