装饰器大乱炖:如何用 NestJS 装饰器给 React SSR 预取数据,并在代码里“偷”得浮生半日闲
各位“码农”朋友们,大家好!
今天我们不聊那些枯燥的架构图,我们聊点有味道的。想象一下,你的 React 应用就像一个正在装修的厨房。你(React)负责把菜炒得香喷喷,把盘子摆得整整齐齐,但是,谁来切菜、谁来买菜、谁来掌勺?
通常情况下,是 getServerSideProps 或者 useEffect。这就好比你既想当米其林大厨,又得亲自去菜市场跟大妈讨价还价,还得自己在后厨剁骨头。这太不专业了,对吧?
今天,我们要请出一位“金牌管家”——NestJS,以及它的杀手锏——装饰器。我们要打造一个架构,让 NestJS 负责所有脏活累活(数据获取、鉴权、数据库操作),React 只负责漂亮地展示。而且,我们还要把这个过程变得高度解耦,甚至能搞出静态化编译的黑科技。
准备好了吗?系好安全带,我们要起飞了。
第一章:痛苦是快乐的前奏——为什么现在的 SSR 这么累?
在开始之前,我们先来吐槽一下现在的 React SSR 现状。你肯定见过这样的代码:
// 这是一个典型的、痛苦的 SSR 代码
export async function getServerSideProps(context) {
const { query } = context;
// 在这里,你还在写业务逻辑,还在处理 API 调用
// 甚至还要在这里写防抖、缓存、日志
const res = await fetch(`https://api.nestjs.com/user/${query.id}`);
const user = await res.json();
// 你还得担心接口挂了怎么办?还得担心 SSR 超时怎么办?
// 好吧,好不容易拿到了数据,现在终于可以返回了
return {
props: {
user,
},
};
}
// 到了组件里,你还得这么用
function Profile({ user }) {
return <div>Hello, {user.name}</div>;
}
看看这段代码,多么“香”啊!如果你把 getServerSideProps 拿走,这个 Profile 组件还能跑吗?不能!因为它和“数据获取”这件事死死地绑在了一起。
这就是我们要解决的痛点:紧耦合。
React 组件不知道数据从哪来,它只知道“哦,我接收到了 props”。而业务逻辑(API 调用)不知道自己在哪个页面上展示,它只是个默默无闻的服务端接口。
我们的目标是什么?是解耦。我们要把“数据获取”从“页面组件”里剥离出来,像穿衣服一样,随时可以换,随时可以挂。
第二章:NestJS 的装饰器哲学——像搭积木一样编程
为什么是 NestJS?因为 NestJS 是装饰器狂魔。它把“方法变成路由”、“类变成控制器”、“参数变成数据绑定”这些事情,全部丢给了装饰器。这简直是为我们的“解耦”方案量身定做的。
在 NestJS 里,定义一个 API 接口就像写一首诗:
// user.controller.ts
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
@Controller('users') // 装饰器:标记这是一个控制器,路径前缀是 users
@UseGuards(AuthGuard) // 装饰器:给这整个控制器加把锁
export class UserController {
@Get(':id') // 装饰器:标记这是一个 GET 请求,参数叫 id
async getUser(@Param('id') id: string) { // 装饰器:把 URL 参数 id 注入到变量里
// 嘿,这里就是业务逻辑!
return { id, name: 'Bobby Tables', email: '[email protected]' };
}
}
你看,多么优雅!接口定义、权限校验、路由映射,全都在那一行行装饰器里。现在,我们要做的,就是让 React 知道,嘿,Profile 组件需要调用 UserController.getUser。
第三章:魔法道具——自定义 React 装饰器
现在,我们要给 React 组件也戴上一顶“帽子”。我们定义一个装饰器,让它告诉 NestJS:“嘿,我需要这个数据!”
我们叫他 @DataFrom,或者更酷一点,@InjectRoute。
// profile.component.ts
import { Component } from 'react';
import { InjectRoute } from './decorators/inject-route.decorator';
// 装饰器:告诉 React,这个组件在 SSR 时需要调用 'users' 路径下的 'getUser' 接口
// 并且把返回值注入到这个组件的 data 属性中
@InjentRoute('users/:id', { id: 123 })
export class ProfileComponent extends Component {
// 注意,我们没有 props.data,我们只需要用
// 未来的 SSR 引擎会自动把这个 data 挂上去
render() {
return (
<div className="profile-card">
<h1>{this.data.name}</h1>
<p>Email: {this.data.email}</p>
</div>
);
}
}
等等,这里有个技术难点。React 组件是运行在浏览器端的,它怎么知道怎么调用 NestJS 接口?难道要在浏览器里 fetch 吗?不行,那就不叫 SSR 了,那叫懒加载。
我们要的是在服务端就把这个数据取回来,然后像普通的 props 一样传给组件。这需要一点“黑魔法”——反射。
第四章:核心引擎——打造 SSR 的“翻译官”
这是整个方案最精妙的地方。我们需要写一个强大的 SSRRenderer 类。它的任务不是渲染 React,而是读懂 React 的装饰器,然后去执行 NestJS 的控制器。
我们利用 JavaScript 的反射机制(Reflect.getMetadata),在构建时或 SSR 初始化时,扫描组件树。
1. 定义元数据
首先,我们需要在 React 组件上标记一些“契约”。
// decorators/inject-route.decorator.ts
import { ReflectiveMetadata } from '@angular/core'; // 或者使用 node-reflect
export function InjectRoute(path: string, params: any = {}) {
return (target: any) => {
// 1. 给组件本身标记路由信息
Reflect.defineMetadata('ROUTE_PATH', path, target);
Reflect.defineMetadata('ROUTE_PARAMS', params, target);
};
}
2. 构建 SSR 引擎
接下来是我们的重头戏。这个类将负责“翻译”。
// services/ssr-renderer.service.ts
import { Request, Response } from 'express';
import { renderToString } from 'react-dom/server';
import { Container } from 'typedi';
import { Controller, Get } from '@nestjs/common';
import * as React from 'react';
class SSRRenderer {
private app: any; // 你的 NestJS 应用实例
constructor(nestApp: any) {
this.app = nestApp;
}
async renderPage(req: Request, res: Response) {
// 1. 找到当前路由对应的 React 组件
// 假设我们有一个路由映射表,或者我们可以通过某种机制(如 Next.js 的页面)获取组件
// 这里为了演示,我们假设我们正在渲染 '/profile' 页面,对应的组件是 ProfileComponent
const PageComponent = require('./profile.component').default;
// 2. 检查组件是否有 @InjectRoute 装饰器
const routePath = Reflect.getMetadata('ROUTE_PATH', PageComponent);
const routeParams = Reflect.getMetadata('ROUTE_PARAMS', PageComponent);
if (!routePath) {
// 如果没有装饰器,说明这是静态组件,直接渲染
const html = renderToString(<PageComponent />);
return this.injectScripts(html);
}
// 3. 哇!发现装饰器!开始执行 NestJS 逻辑
console.log(`[${new Date().toISOString()}] 🔍 正在请求 NestJS 端点: ${routePath}`);
try {
// 我们需要模拟 NestJS 的请求上下文
// 注意:这里需要根据你的 NestJS 版本和配置进行调整,通常涉及 HttpAdapter 的使用
const controllerInstance = new PageComponent; // 这里需要更复杂的反射来找到对应的 Controller 实例
// 这里是“黑魔法”时刻:我们如何从 React 装饰器直接映射到 NestJS 控制器方法?
// 最简单的方法是:约定。装饰器里的字符串必须和 NestJS 路由字符串匹配。
// 我们手动构建一个模拟的 NestJS Request 对象
const mockReq = {
params: routeParams, // 从装饰器传来的参数,注入到 NestJS 的 @Param
query: {}, // 可以扩展装饰器支持 query
};
// 我们需要找到对应的 Controller 方法并执行
// 这通常需要 NestJS 的反射系统或者手动路由匹配
// 为了简化演示,我们假设有一个方法 `findControllerMethod(routePath)` 存在
const controllerMethod = this.findControllerMethod(routePath);
// 执行 NestJS 控制器方法
const data = await controllerMethod(mockReq);
// 4. 拿到数据,渲染组件
// 我们需要一种方式把数据注入给组件。
// React 渲染时,我们传入一个特殊属性,比如 '_ssrData'
const html = renderToString(<PageComponent _ssrData={data} />);
return this.injectScripts(html);
} catch (error) {
console.error('SSR 数据获取失败', error);
// 如果失败了怎么办?通常降级到客户端渲染,或者报错页面
return this.renderErrorPage(error);
}
}
// 模拟查找控制器的逻辑(实际开发中需要集成 NestJS 的 MetadataStorage)
private findControllerMethod(routePath: string) {
// 这里是伪代码,实际逻辑是遍历所有 Controller 的装饰器定义
// 匹配 path
// 返回 Controller 的实例和方法
// 为了演示流畅性,我们这里直接硬编码一下逻辑或者利用 NestJS 的 Injector
// 实际项目中,我们可以在构建时生成一个映射表:'/users/:id' -> UserController.getUser
return async (req) => {
return { id: req.params.id, name: 'Fetched Data' };
};
}
private injectScripts(html: string) {
return `
<!DOCTYPE html>
<html>
<head>
<script src="/bundle.js"></script>
</head>
<body>
<div id="root">${html}</div>
</body>
</html>
`;
}
}
第五章:高度解耦——谁来负责数据?
看到上面的代码,你可能会问:“这还没解耦呢,你的 SSRRenderer 不还是要硬编码找方法吗?”
别急,这才是高级玩法。
我们要利用 NestJS 强大的依赖注入容器。
1. React 装饰器定义契约
首先,让 React 组件不再关心具体的 API 地址,只关心“数据契约”。
// decorators/data-provider.decorator.ts
export function ProvideData(key: string, options?: any) {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
// 标记这个组件方法或这个组件本身需要提供某种数据
Reflect.defineMetadata('DATA_KEY', key, target);
Reflect.defineMetadata('DATA_OPTIONS', options, target);
};
}
2. NestJS 控制器实现契约
然后,NestJS 控制器去实现这个契约。
// contracts/user.contract.ts
// 定义数据契约
export interface UserContract {
id: string;
name: string;
email: string;
}
// user.controller.ts
@Controller('users')
export class UserController {
// NestJS 负责实现这个接口
@Get(':id')
async getUser(@Param('id') id: string): Promise<UserContract> {
// 假设这是从数据库或者远程服务获取的
return this.userService.findById(id);
}
}
3. 动态适配器(The Glue)
现在,我们需要一个极其聪明的 SSRRenderer。它不需要硬编码路径。
- 它扫描 React 组件,发现有
ProvideData('UserContract')。 - 它去寻找注册了
UserContract的 NestJS 控制器(利用 NestJS 的 Dependency Injection)。 - 它自动生成 API 调用。
代码示例(伪代码逻辑):
class SmartSSRRenderer {
constructor(private container: Container) {} // NestJS 的 Container
async renderComponent(Component: React.ComponentType<any>) {
// 1. 扫描组件元数据
const dataKey = Reflect.getMetadata('DATA_KEY', Component);
if (!dataKey) return renderToString(<Component />);
// 2. 从 DI 容器中查找契约对应的提供者
// 这里需要利用 NestJS 的 Provider 扫描逻辑
const provider = this.container.get(dataKey);
// 或者更准确地说,找到注入了 UserContract 的那个 Controller 实例
// 3. 执行数据获取
// 这一步可以利用 NestJS 的 Pipe 机制,确保数据符合契约
const data = await provider.execute();
// 4. 注入数据并渲染
return renderToString(<Component _ssrData={data} />);
}
}
这种架构下,React 组件只需要知道“我想要个 UserContract”。NestJS 只需要知道“我负责提供 UserContract”。中间的SSRRenderer 负责把它们连起来。
如果你想把数据源换成 MongoDB,你只需要写一个新的 Controller 实现 UserContract,不需要动 React 代码!这就是真正的解耦。
第六章:静态化编译——把动态变成静态
既然我们已经实现了 SSR,为什么还要搞静态化?因为 SSR 是实时的,慢,贵。静态化是提前算好的,快,便宜。
有了上面的装饰器架构,实现静态化简直易如反掌。你不需要重新发明轮子,你只需要在构建阶段运行一次 SmartSSRRenderer。
静态生成流程
- 配置路由列表:告诉程序,
/home,/about,/profile/:id这些页面需要静态化。 - 遍历组件:针对每个路由,找到对应的 React 组件。
- 触发装饰器:运行
SmartSSRRenderer.renderComponent。 - 获取数据:NestJS 跑起来,执行 API 调用。
- 生成 HTML:拿到 HTML 字符串,保存到
dist目录下,命名为profile-123.html。 - 发布:把这些 HTML 部署到 CDN 上,或者用 Webpack 打包成静态站点。
代码示例:自动化构建脚本
// scripts/build-static.ts
import { NestFactory } from '@nestjs/core';
import { Container } from 'typedi';
import { SmartSSRRenderer } from './services/ssr-renderer.service';
import { ProfileComponent } from '../src/pages/profile.component';
import { HomePageComponent } from '../src/pages/home.component';
async function buildStaticSite() {
console.log('🚀 开始构建静态站点...');
// 1. 启动 NestJS 核心应用(或者只启动 Injector)
const app = await NestFactory.create(AppModule);
await app.init();
const container = app.get(Container);
// 2. 实例化渲染器
const renderer = new SmartSSRRenderer(container);
// 3. 定义需要静态化的页面
const routes = [
{ path: '/home', component: HomePageComponent },
{ path: '/profile/1', component: ProfileComponent }, // 假设 id=1
{ path: '/profile/2', component: ProfileComponent },
];
// 4. 循环渲染
for (const route of routes) {
console.log(`正在生成: ${route.path}`);
// 这里的逻辑和 SSR 渲染完全一样,只是不响应 HTTP 请求,而是输出文件
const html = await renderer.renderComponent(route.component);
// 写入文件系统
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../public', route.path === '/' ? 'index.html' : `${route.path}.html`);
fs.writeFileSync(filePath, html);
console.log(`✅ 完成: ${filePath}`);
}
console.log('🎉 所有页面构建完毕!');
process.exit(0);
}
buildStaticSite();
看,通过这种方式,你既拥有了 React 的交互性,又拥有了 HTML 的加载速度。而且,你的代码结构非常清晰:UI 组件和业务逻辑互不打扰。
第七章:避坑指南与高级玩法
虽然这个方案很美,但我们要承认,它也有坑。
1. 循环依赖的噩梦
在 SSR 环境下,Node.js 的循环引用处理比较严格。如果你的 NestJS 模块互相依赖,或者 React 组件和 Controller 逻辑纠缠不清,编译时会报错。记住:始终把逻辑放在 NestJS,把 UI 放在 React。
2. 装饰器的开销
装饰器虽然好用,但在运行时解析元数据(Reflect.getMetadata)会有一定的性能损耗。不过,对于 SSR 渲染这种主要瓶颈在 I/O(网络请求)和计算上(JSX 编译)的操作,这点 CPU 开销完全可以忽略不计。
3. 类型安全
TypeScript 的类型推导在装饰器中可能会“迷路”。你需要在装饰器定义和组件使用时做好类型声明,否则 this.data 可能会是 any 类型。建议使用一些库(如 ts-morph)在构建时生成类型声明文件,而不是依赖运行时反射。
第八章:终极形态——拥抱微前端
如果你觉得上面的方案还不够刺激,那我们再拔高一点。
想象一下,你的公司有多个团队。
- 前端团队负责 UI 组件库,他们用的是 Vue 或者原生 JS。
- 后端团队负责业务逻辑,他们用 NestJS 写了一堆接口。
他们想做一个 SSR 站点,但前端不想用 React,后端也不想换框架。
利用我们的装饰器解耦方案,这是可以实现的!
- NestJS:定义接口
@Get('cart'),返回CartData。 - Vue 团队:写组件,用
@InjectRoute('cart')注入数据。 - SSR 引擎:检测到组件是 Vue,调用对应的 Vue SSR 引擎;检测到数据来源是 NestJS,调用 NestJS 控制器。
只要约定好接口契约,语言和框架都不是问题。这就是技术无关性带来的自由。
总结
同学们,今天我们用 NestJS 的装饰器,给 React SSR 预取数据找了一个全新的姿势。
我们抛弃了 getServerSideProps 这种把 UI 和逻辑塞在一起的脏活,转而拥抱了装饰器驱动的依赖注入。我们用 React 装饰器定义“我要什么”,用 NestJS 装饰器定义“我有什么”。
这不仅仅是代码结构的优化,更是一种编程哲学的提升。它让我们把关注点从“怎么把数据传给 UI”转移到了“业务契约是什么”上。
当你下次写代码时,试着在组件上方加个 @InjectRoute,你会发现,代码竟然变得如此简洁,就像在亚马逊丛林里穿上了迷彩服一样——浑然天成,却又暗藏玄机。
好了,今天的讲座就到这里。如果你在实践过程中遇到了装饰器报错,别慌,Ctrl+C,Ctrl+V,重启服务,问题通常就解决了。
下课!记得把代码跑起来!