我有一个想法使用BUN运行时(https://bun.sh)来运行Angular Server端渲染。我首先从头开始创建一个简单的“ Hello World”示例。之后,我尝试通过通过ng add @nguniversal/express-engine
命令合并Express Engine来运行Express服务器。
让我们首先创建一个基本的独立应用,然后添加Express引擎:
$ ng new bun-universal --minimal --style scss --routing false --standalone
$ cd bun-universal
$ yarn ng add @nguniversal/express-engine
接下来,让我们执行示意图提供的命令以构建浏览器和服务器捆绑包:
$ yarn build:ssr
之后,我们将尝试使用BUN启动服务器:
$ bun dist/bun-universal/server/main.js
输出应显示Node Express server listening on http://localhost:4000
。当我们在浏览器中导航到“ Localhost”时,我们预计将看到服务器端渲染的应用程序,其中Angular在运行NG NEW时会生成的内容。但是,我们实际观察到的是以下内容:
我花了大量时间来试图理解失败的确切性质。首先,我在根目录中创建了一个名为server.js
的附加文件。该文件包含一个基本的独立Express服务器,我用来确定它是否会提供有效的响应:
// server.js
import path from 'node:path';
import express from 'express';
const app = express();
const distFolder = path.join(process.cwd(), 'dist/bun-universal/browser');
app.get('*.*', express.static(distFolder));
app.get('/', (req, res) => {
res.sendFile(path.join(distFolder, 'index.html'));
});
app.listen(4200, () => {
console.log(`Node Express server listening on http://localhost:4200`);
});
上面的代码作为静态文件服务器的功能,仅用请求的文件响应特定的URL。我首先导入服务器端渲染所需的角依赖关系。在此过程中,我观察到zone.js/node
在某些情况下破坏了预期的行为。如果我们在server.js
文件顶部包含以下行:
import 'zone.js/node';
当我们使用bun server.js
再次运行服务器时,显然服务器永远不会响应。这表明由zone.js
加载的某些补丁正在引起干扰。这些补丁似乎会影响在BUN运行时暴露的本机类,也会影响fs
之类的内置模块。值得注意的是,Express依赖于setImmediate
和fs
模块的内部用法(在send
软件包中,用于流媒体文件)。有趣的是,如果我们禁用node_timers
补丁,服务器将变得功能性:
globalThis.__Zone_disable_node_timers = true;
require('zone.js/node');
import path from 'node:path';
import express from 'express';
通过禁用node_timers
补丁,zone.js
库不知道任何计划的setTimeout
或setInterval
任务。因此,Angular不会等待所有这些计划的任务被调用,从而导致序列化HTML的早期返回。我遇到的另一个问题涉及与承诺相关的补丁。在这种情况下,bun并不能将ZoneAwarePromise
识别为Promise
,并且无法等待承诺的解决方案。以下示例说明了此问题:
app.get('/', async (req, res) => {
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('after await');
res.sendFile(path.join(distFolder, 'index.html'));
});
使用此代码,服务器将再次返回默认的BUN页面,显示Fetch没有返回响应对象的消息。但是,在延迟2秒后,您仍然会观察到console.log
。如果我们使用以下代码禁用承诺补丁:
globalThis.__Zone_disable_ZoneAwarePromise = true;
然后,我们将在2秒延迟后观察index.html
的含量。
考虑到所有上述要点,我得出的结论是,我需要使用zone.js
禁用服务器。这是因为zone.js
应用的补丁与BUN运行时不兼容。
弥合差距
让我们从编辑server.ts
文件开始。我们可以从Express.Strespation中删除maxAge
选项。由于目前不需要缓存。此外,让我们从文件顶部删除zone.js/node
的导入。
如果我们遵循以下步骤:构建应用程序并再次运行它,我们将遇到NG0908
异常。此例外表明,Angular需要zone.js
,因为NgZone
构造函数依赖于它:
if (typeof Zone == 'undefined') {
throw new RuntimeError(908 /* RuntimeErrorCode.MISSING_ZONEJS */, ngDevMode && `In this configuration Angular requires Zone.js`);
}
Zone.assertZonePatched();
要解决上述问题,我们需要用NoopNgZone
替换NgZone
注射者。让我们在src/app
目录中编辑app.server.config.ts
文件:
import {
mergeApplicationConfig,
ApplicationConfig,
NgZone,
ɵNoopNgZone,
} from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
{ provide: NgZone, useClass: ɵNoopNgZone },
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
再次构建应用程序,运行服务器并使用curl
。您应该观察以下渲染的html:
使用上述设置,此方法仅对在渲染过程中不使用任何异步API的应用程序成功起作用。但是,这不是现实的情况,因为HTTP请求通常是在服务器端渲染期间提出的,通常是一次获取数据并将其保存到传输状态。
Angular等待直到完成渲染过程中的所有任务完成,然后将HTML序列归还给客户端:
await applicationRef.isStable.pipe((first((isStable: boolean) => isStable))).toPromise();
让我们将provideHttpClient()
添加到app.config.ts
文件中,并将以下代码合并到我们的AppComponent
中:
export class AppComponent {
title = 'bun-universal';
constructor() {
inject(HttpClient)
.get('https://jsonplaceholder.typicode.com/todos/1')
.subscribe(() => {
this.title = 'bun-universal-v2';
});
}
}
如果我们构建了应用程序,再次运行服务器并执行curl
请求,我们会观察到title
保留bun-universal
:
<h1>Welcome to bun-universal!</h1>
这是因为该应用程序立即变得稳定,因为没有运行的微型和宏任务。
我们需要实施一种机制,才能等到渲染期间计划的所有HTTP请求均已完成。 Angular已经包括一个名为ɵInitialRenderPendingTasks
的类,该类封装了行为主题hasPendingTasks
。每当完成所有HTTP任务时,hasPendingTasks
的值会更改。此类由@angular/common/http
软件包中的HttpInterceptorHandler
使用。启动HTTP请求时,Angular会增加待处理任务的计数。
我们还需要在ApplicationRef
中实现自定义的isStable
行为,因为默认实现依赖于ngZone.isStable
。此外,必须等到该应用程序引导后才订阅hasPendingTasks
。这种预防措施是必要的,因为在安排任何HTTP请求之前,hasPendingTasks
可能会过早发射false
。
接下来,我们将创建AppBootstrapped
类。请注意,我将所有内容都放在app.config.server.ts
文件中:
@Injectable({ providedIn: 'root' })
export class AppBootstrapped extends BehaviorSubject<boolean> {
constructor() {
super(false);
}
}
当应用程序引导时,上面的主题将呈现True(当调用ApplicationRef.bootstrap
并调用从APP_BOOTSTRAP_LISTENER
注入令牌解决的听众时)。
和扩展了原始ApplicationRef
并提供自定义isStable
实现的自定义类:
@Injectable()
export class NoopNgZoneApplicationRef extends ApplicationRef {
override isStable: Observable<boolean>;
constructor() {
super();
const pendingTasks = inject(ɵInitialRenderPendingTasks);
this.isStable = inject(AppBootstrapped).pipe(
filter(appBootstrapped => appBootstrapped),
mergeMap(() => pendingTasks.hasPendingTasks),
map(hasPendingTasks => !hasPendingTasks)
);
}
}
我们注入AppBootstrapped
类,等待该应用程序被引导,然后将订阅切换为hasPendingTasks
,将其与isStable
保持一致的值。 app.server.config.ts
的最终内容:
import {
mergeApplicationConfig,
ApplicationConfig,
NgZone,
ɵNoopNgZone,
ApplicationRef,
Injectable,
inject,
ɵInitialRenderPendingTasks,
APP_BOOTSTRAP_LISTENER,
} from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { BehaviorSubject, Observable, filter, map, mergeMap } from 'rxjs';
import { appConfig } from './app.config';
@Injectable({ providedIn: 'root' })
export class AppBootstrapped extends BehaviorSubject<boolean> {
constructor() {
super(false);
}
}
@Injectable()
export class NoopNgZoneApplicationRef extends ApplicationRef {
override isStable: Observable<boolean>;
constructor() {
super();
const pendingTasks = inject(ɵInitialRenderPendingTasks);
this.isStable = inject(AppBootstrapped).pipe(
filter(appBootstrapped => appBootstrapped),
mergeMap(() => pendingTasks.hasPendingTasks),
map(hasPendingTasks => !hasPendingTasks)
);
}
}
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
{ provide: NgZone, useClass: ɵNoopNgZone },
{ provide: ApplicationRef, useClass: NoopNgZoneApplicationRef },
{
provide: APP_BOOTSTRAP_LISTENER,
multi: true,
useFactory: () => {
const appBootstrapped = inject(AppBootstrapped);
return () => appBootstrapped.next(true);
},
},
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
最后一步是在更改标题时手动运行更改检测,因为ApplicationRef.tick
方法没有自动触发器。重要的是要注意,在OnPush
组件中,手动更改检测触发也是必要的。因此,可以在浏览器和服务器之间互换代码:
export class AppComponent {
title = 'bun-universal';
constructor() {
const ref = inject(ChangeDetectorRef);
inject(HttpClient)
.get('https://jsonplaceholder.typicode.com/todos/1')
.subscribe(() => {
this.title = 'bun-universal-v2';
ref.detectChanges();
});
}
}
现在,在构建和运行服务器后,再次执行卷曲请求:
我们可以注意到<h1>Welcome to bun-universal-v2</h1>
元素。
跟踪计时器
当前没有能力跟踪使用setTimeout
安排的计时器,也没有实际的理由可以在代码在服务器端运行时安排计时器。该限制背后的主要理由是,任何计时器都可能在响应中引入延迟。在常规方法中,Angular将在响应之前等待应用程序稳定性(await appRef.isStable
)。虽然可以用isPlatformBrowser
包装所有计时器,但通常是对这些计时器安排的代码的控制有限。
考虑一个方案,有人订阅了一个值流,并使用debounceTime
订阅流,该方案在内部使用asyncScheduler
默认情况下。每次溪流发出值时,操作员都会重新安排内部计时器。
服务器端运行的代码通常与角度代码的同步或异步性质有关。为了坦率,浏览器中通常使用计时器和动画框架来增强UI性能并防止渲染过程中的潜在帧下降。但是,在服务器端,帧下降并不关心,因此没有理由安排计时器。如果在服务器端执行代码的情况
export class AppComponent {
constructor() {
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
source$
.pipe(isBrowser ? debounceTime(1000) : identity, takeUntilDestroyed())
.subscribe(() => {
// ...
});
}
}
但是,正如我之前提到的,由于我们没有必要在代码中明确安排计时器,因此可能仍在服务器端使用的第三方库安排它们。
添加和删除任务
让我们考虑以下示例:我们lazy-load-load logus生成随机nonce的库(懒惰节点库库所需转移状态:
export class AppComponent implements AfterViewInit {
title = 'bun-universal';
private readonly _transferState = inject(TransferState);
private readonly _isServer = isPlatformServer(inject(PLATFORM_ID));
async ngAfterViewInit(): Promise<void> {
if (this._isServer) {
const { cryptoRandomStringAsync } = await import('crypto-random-string');
const nonce = await cryptoRandomStringAsync({
length: 20,
type: 'base64',
});
this._transferState.set(CSP_NONCE_KEY, nonce);
}
}
}
如果我们运行服务器并执行curl
命令,我们会注意到状态未序列化。在服务器端,导入等效于Promise.resolve().then(() => require(...))
。 import
Microtask安排在appRef.isStable.toPromise()
Microtask之前。同样,cryptoRandomStringAsync
也安排在isStable
Microtask之前。但是,由于cryptoRandomStringAsync
计划其他微型掩体,isStable
MicroTask早些时候解决了。任何新安排的微型掩体都将添加到微型箱队列的末端。这就是为什么在传输状态设置值之前将HTML序列化的原因。
由于我们已经熟悉了InitialRenderPendingTasks
类,因此我们可以通过通知Angular的功能受益于其功能,直到完成之前,仍有已待处理的任务:
export class AppComponent implements AfterViewInit {
title = 'bun-universal';
private readonly _transferState = inject(TransferState);
private readonly _isServer = isPlatformServer(inject(PLATFORM_ID));
private readonly _pendingTasks = inject(ɵInitialRenderPendingTasks);
async ngAfterViewInit(): Promise<void> {
if (this._isServer) {
const taskId = this._pendingTasks.add();
const { cryptoRandomStringAsync } = await import('crypto-random-string');
const nonce = await cryptoRandomStringAsync({
length: 20,
type: 'base64',
});
this._transferState.set(CSP_NONCE_KEY, nonce);
this._pendingTasks.remove(taskId);
}
}
}
基准测试节点和bun
bun的“ Hello World”服务器渲染React应用具有基准测试,指出它的速度比Deno快2倍,并且比节点快3倍。
我选择了创建Docker图像并在容器中本地运行它们。随后,我将利用autocannon
工具在根端点上进行负载测试。
让我们从创建一个.dockerignore
文件开始,以防止在构建过程中将不必要的文件夹复制到容器中:
.angular
dist
node_modules
.DS_Store
现在,让我们继续添加Dockerfile
:
FROM node:18-alpine AS build
WORKDIR /tmp
COPY . .
RUN yarn --pure-lockfile && yarn build:ssr
# FROM oven/bun
FROM node:18-alpine
WORKDIR /usr/src/app
COPY --from=build /tmp/dist ./dist
EXPOSE 4200
# CMD ["bun", "dist/bun-universal/server/main.js"]
CMD ["node", "dist/bun-universal/server/main.js"]
观察评论的FROM
和CMD
命令。构建节点和BUN图像的说明非常相似,只有很小的差异。
我将通过交替这些命令来构建2张图像。为节点构建时,我将评论FROM oven/bun
及其CMD
。相反,在为bun构建时,我会发表评论FROM node:18-alpine
及其CMD
:
$ docker build -t node-universal .
$ # Now comment `FROM` and `CMD` for Node and uncomment for Bun
$ docker build -t bun-universal .
现在让我们运行node-universal
容器并使用autocannon
:
$ docker run -dp 4200:4200 -e 'PORT=4200' node-universal
$ autocannon -c 100 -d 10 http://localhost:4200
$ docker stop containerId
让我们对bun做同样的事情:
$ docker run -dp 4200:4200 -e 'PORT=4200' bun-universal
$ autocannon -c 100 -d 10 http://localhost:4200
$ docker stop containerId
因此,是3K请求节点和BUN的5K请求。请注意,这些结果实际上可能在操作系统和硬件之间有所不同。我在Mac上进行了这些测试。在安装Ubuntu的另一台计算机上,我观察到了11K的节点请求和14K BUN请求。即使对于一个简单的“ Hello World”应用程序,这些结果也可能有些不稳定,而且我无法确定在现实生活中的bun是否会更快。