使用NestJs 构建的SSR 混合渲染方案(重构篇)

2,876 阅读5分钟

介绍

之前与掘友分享过一篇 关于 #从零构建自己的SSR同构框架... 的这篇文章 对基于 NestJS 构建SSR 的方式只进行了简单的介绍里面还有许多的不足的地方,这篇文章是用来补充和 填坑的,后续我会不断的完善。 同样的 首先声明 本文的内容 参考和引用了部分 Newegg(Newegg.com) 的设计 若有侵权请联系

先简单的介绍一下 功能, 在 Newegg 我们使用 NodeJS(Nestjs) + React 来支撑 整个 Newegg.com 站点的前端。

  1. 基于NestJs 实现BFF。(在Nest我们会组装各个Service 返回的数据,然后做业务)
  2. 基于 NestJS 提供的API 我们把React 的SSR 整合了进去。
  3. 多Img 部署,比如HomeModule 是独立的一个Img 只管Home相关的内容,NasModule 也是一个独立的Img,再结合Newegg的K8s 基础设施,对流量和并发融灾什么的完全可以照顾周全。

本文章提供了一个简易的实现,它支持了以上的四个功能 (由于多Img部署需要docker集群我就没有往下更了,日后补上,不过我想 文章列出的内容 ,你已经完全知道接下来怎么做了 对吗?)

实现

首先我们改造之前的文章提到的代码

主要是目标就是把 vite 的全丢掉,基础的SSR 保留着除外

  1. 我们需要移除所有的vite相关的东西

(为什么需要移除呢?主要是自己用不来还没有研究透)

image.png

~ 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

image.png

  1. 聚焦如何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 的就完成了,下一步我们考虑一下 资源文件如何处理

  1. 聚焦Assets资源的处理

经过上面的改造之后 我们仅拿到了SSR的内容,现在我们需要处理资源 我们新建一个static 目录里面存放我们的资源文件,

image.png

我们还需要对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 下了

image.png

然后我们需要 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的

image.png

"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…