概述
这次 Node.js 服务框架的调研将着点于各框架功能、请求流程的组织和介入方式,以对前端 Node.js 服务设计和对智联 Ada 架构改进提供参考,不过多关注具体实现。
最终选取了以下具有代表性的框架:
- Next.js、Nuxt.js:它们是分别与特定前端技术 React、Vue 绑定的前端应用开发框架,有一定的相似性,可以放在一起进行调研对比。
- Nest.js:是“Angular 的服务端实现”,基于装饰器。可以使用任何兼容的 http 提供程序,如 Express、Fastify 替换底层内核。可用于 http、rpc、graphql 服务,对提供更多样的服务能力有一定参考价值。
Next.js、Nuxt.js
这两个框架的重心都在 Web 部分,对 UI 呈现部分的代码的组织方式、服务器端渲染功能等提供了完善的支持。- Next.js:React Web 应用框架,调研版本为 12.0.x。
- Nuxt.js:Vue Web 应用框架,调研版本为 2.15.x。
功能
首先是路由部分:
- 页面路由:
- 相同的是两者都遵循文件即路由的设计。默认以 pages 文件夹为入口,生成对应的路由结构,文件夹内的所有文件都会被当做路由入口文件,支持多层级,会根据层级生成路由地址。同时如果文件名为 index 则会被省略,即 /pages/users 和 /pages/users/index 文件对应的访问地址都是 users。
- 不同的是,根据依赖的前端框架的不同,生成的路由配置和实现不同:
- Next.js:由于 React 没有官方的路由实现,Next.js 做了自己的路由实现。
- Nuxt.js:基于 vue-router,在编译时会生成 vue-router 结构的路由配置,同时也支持子路由,路由文件同名的文件夹下的文件会变成子路由,如 article.js,article/a.js,article/b.js,a 和 b 就是 article 的子路由,可配合
<nuxt-child />
组件进行子路由渲染。
- api 路由:
- Next.js:在 9.x 版本之后添加了此功能的支持,在 pages/api/ 文件夹下(为什么放在pages文件夹下有设计上的历史包袱)的文件会作为 api 生效,不会进入 React 前端路由中。命名规则相同,pages/api/article/[id].js -> /api/article/123。其文件导出模块与页面路由导出不同,但不是重点。
- Nuxt.js:官方未提供支持,但是有其他实现途径,如使用框架的 serverMiddleware 能力。
- 动态路由:两者都支持动态路由访问,但是命名规则不同:
- Next.js:使用中括号命名,/pages/article/[id].js -> /pages/article/123。
- Nuxt.js:使用下划线命名,/pages/article/_id.js -> /pages/article/123。
- 路由加载:两者都内建提供了 link 类型组件(
Link
和 NuxtLink
),当使用这个组件替代 <a></a>
标签进行路由跳转时,组件会检测链接是否命中路由,如果命中,则组件出现在视口后会触发对对应路由的 js 等资源的加载,并且点击跳转时使用路由跳转,不会重新加载页面,也不需要再等待获取渲染所需 js 等资源文件。 - 出错兜底:两者都提供了错误码响应的兜底跳转,只要 pages 文件夹下提供了 http 错误码命名的页面路由,当其他路由发生响应错误时,就会跳转到到错误码路由页面。
在根据文件结构生成路由配置之后,我们来看下在代码组织方式上的区别:
- 路由组件:两者没有区别,都是使用默认导出组件的方式决定路由渲染内容,React 导出 React 组件,Vue 导出 Vue 组件:
- Next.js:一个普普通通的 React 组件:
export default function About() {
return <div>About us</div>
}
- Nuxt.js:一个普普通通的 Vue 组件:
<template>
<div>About us</div>
</template>
<script>
export default {}
<script>
路由组件外壳:在每个页面路由组件之外还可以有一些预定义外壳来承载路由组件的渲染,在 Next.js 和 Nuxt.js 中都分别有两层外壳可以自定义:
- 容器:可被页面路由组件公用的一些容器组件,内部会渲染页面路由组件:
- Next.js:需要改写 pages 根路径下的 _app.js,会对整个 Next.js 应用生效,是唯一的。其中
<Component />
为页面路由组件,pageProps
为预取的数据,后面会提到
import '../styles/global.css'
export default function App({ Component, pageProps }) {
return <Component {pageProps} />
}
- Nuxt.js:称为 Layout,可以在 layouts 文件夹下创建组件,如 layouts/blog.vue,并在路由组件中指明 layout,也就是说,Nuxt.js 中可以有多套容器,其中
<Nuxt />
为页面路由组件:
<template>
<div>
<div>My blog navigation bar here</div>
<Nuxt /> // 页面路由组件
</div>
</template>
- 文档:即 html 模板,两者的 html 模板都是唯一的,会对整个应用生效:
- Next.js:改写 pages 根路径下唯一的 _document.js,会对所有页面路由生效,使用组件的方式渲染资源和属性:
mport Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
- Nuxt.js:改写根目录下唯一的 App.html,会对所有页面路由生效,使用占位符的方式渲染资源和属性:
<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
<head {{ HEAD_ATTRS }}>
{{ HEAD }}
</head>
<body {{ BODY_ATTRS }}>
{{ APP }}
</body>
</html>
- head 部分:除了在 html 模板中直接写 head 内容的方式,如何让不同的页面渲染不同的 head 呢,我们知道 head 是在组件之外的,那么两者都是如何解决这个问题的呢?
- Next.js:可以在页面路由组件中使用内建的 Head 组件,内部写 title、meta 等,在渲染时就会渲染在 html 的 head 部分:
import Head from 'next/head'
function IndexPage() {
return (
<div>
<Head>
<title>My page title</title>
<meta property="og:title" content="My page title" key="title" />
</Head>
<Head>
<meta property="og:title" content="My new title" key="title" />
</Head>
<p>Hello world!</p>
</div>
)
}
export default IndexPage
Nuxt.js:同样可以在页面路由组件中配置,同时也支持进行应用级别配置,通用的 script、link 资源可以写在应用配置中:- 在页面路由组件配置:使用 head 函数的方式返回 head 配置,函数中可以使用 this 获取实例:
<template>
<h1>{{ title }}</h1>
</template>
<script>
export default {
data() {
return {
title: 'Home page'
}
},
head() {
return {
title: this.title,
meta: [
{
name: 'description',
content: 'Home page description'
}
]
}
}
}
</script>
- nuxt.config.js 进行应用配置:
export default {
head: {
title: 'my website title',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: 'my website description' }
],
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }]
}
}
除去基本的 CSR(客户端渲染),(服务器端渲染)也是必须的,我们来看下两者都是怎样提供这种能力的,在此之外又提供了哪些渲染能力?
- 服务器端渲染:众所周知的是服务器端渲染需要进行数据预取,两者的预取用法有何不同?
- Next.js:
- 可以在页面路由文件中导出 getServerSideProps 方法,Next.js 会使用此函数返回的值来渲染页面,返回值会作为 props 传给页面路由组件:
- 上文提到的容器组件也有自己的方法,不再介绍。
- 渲染过程的最后,会生成页面数据与页面构建信息,这些内容会写在
<script id="__NEXT_DATA__"/>
中渲染到客户端,并被在客户端读取。
- Nuxt.js:数据预取方法有两个,分别是 asyncData、fetch:
- asyncData:组件可导出 asyncData 方法,返回值会和页面路由组件的 data 合并,用于后续渲染,只在页面路由组件可用。
- fetch:在 2.12.x 中增加,利用了 Vue 服务端渲染的 serverPrefetch,在每个组件都可用,且会在服务器端和客户端同时被调用。
- 渲染过程的最后,页面数据与页面信息写在 window.NUXT 中,同样会在客户端被读取。
- 静态页面生成 SSG:在构建阶段会生成静态的 HTML 文件,对于访问速度提升和做 CDN 优化很有帮助:
- Next.js:在两种条件下都会触发自动的 SSG:
- 页面路由文件组件没有 getServerSideProps 方法时;
- 页面路由文件中导出 getStaticProps 方法时,当需要使用数据渲染时可以定义这个方法:
export async function getStaticProps(context) {
const res = await fetch(`https://.../data`)
const data = await res.json()
if (!data) {
return {
notFound: true,
}
}
return {
props: { data }
}
}
- Nuxt.js:提供了命令 generate 命令,会对整站生成完整的 html。
- 不论是那种渲染方式,在客户端呈现时,页面资源都会在头部通过 rel="preload" 的方式提前加载,以提前加载资源,提升渲染速度。
- Next.js:可以在 pages 文件夹下的各级目录建立 _middleware.js 文件,并导出中间件函数,此函数会对同级目录下的所有路由和下级路由逐层生效。
- Nuxt.js:中间件代码有两种组织方式:
- 应用级别:在 middleware 中创建同名的中间件文件,这些中间件将会在路由渲染前执行,然后可以在 nuxt.config.js 中配置:
// middleware/status.js 文件
export default function ({ req, redirect }) {
// If the user is not authenticated
// if (!req.cookies.authenticated) {
// return redirect('/login')
// }
}
// nuxt.config.js
export default {
router: {
middleware: 'stats'
}
}
- 组件级别:可以在 layout或页面组件中声明使用那些 middleware:
export default {
middleware: ['auth', 'stats']
}
- 也可以直接写全新的 middleware:
<script>
export default {
middleware({ store, redirect }) {
// If the user is not authenticated
if (!store.state.authenticated) {
return redirect('/login')
}
}
}
</script>
在编译构建方面,两者都是基于 webpack 搭建的编译流程,并在配置文件中通过函数参数的方式暴露了 webpack 配置对象,未做什么限制。其他值得注意的一点是 Next.js 在 v12.x.x 版本中将代码压缩代码和与原本的 babel 转译换为了 swc,这是一个使用 Rust 开发的更快的编译工具,在前端构建方面,还有一些其他非基于 JavaScript 实现的工具,如 ESbuild。
在扩展框架能力方面,Next.js 直接提供了较丰富的服务能力,Nuxt.js 则设计了模块和插件系统来进行扩展。
Nest.js
Nest.js 是“Angular 的服务端实现”,基于装饰器。Nest.js 与其他前端服务框架或库的设计思路完全不同。我们通过查看请求生命周期中的几个节点的用法来体验下 Nest.js 的设计方式。
先来看下 Nest.js 完整的的生命周期:- 收到请求
- 中间件
- 全局绑定的中间件
- 路径中指定的 Module 绑定的中间件
- 守卫
- 全局守卫
- Controller 守卫
- Route 守卫
- 拦截器(Controller 之前)
- 全局
- Controller 拦截器
- Route 拦截器
- 管道
- 全局管道
- Controller 管道
- Route 管道
- Route 参数管道
- Controller(方法处理器)
- 服务
- 拦截器(Controller 之后)
- Router 拦截器
- Controller 拦截器
- 全局拦截器
- 异常过滤器
- 路由
- 控制器
- 全局
- 服务器响应
可以看到根据功能特点拆分的比较细,其中拦截器在 Controller 前后都有,与 Koa 洋葱圈模型类似。
功能设计
首先看下路由部分,即最中心的 Controller:- 路径:使用装饰器装饰 @Controller 和 @GET 等装饰 Controller 类,来定义路由解析规则。如:
import { Controller, Get, Post } from '@nestjs/common'
@Controller('cats')
export class CatsController {
@Post()
create(): string {
return 'This action adds a new cat'
}
@Get('sub')
findAll(): string {
return 'This action returns all cats'
}
}
- 定义了 /cats post 请求和 /cats/sub get 请求的处理函数。
- 响应:状态码、响应头等都可以通过装饰器设置。当然也可以直接写。如:
@HttpCode(204)
@Header('Cache-Control', 'none')
create(response: Response) {
// 或 response.setHeader('Cache-Control', 'none')
return 'This action adds a new cat'
}
- 参数解析:
@Post()
async create(@Body() createCatDto: CreateCatDto) {
return 'This action adds a new cat'
}
- 请求处理的其他能力方式类似。
再来看看生命周期中其中几种其他的处理能力:
- 中间件:声明式的注册方法:
@Module({})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
// 应用 cors、LoggerMiddleware 于 cats 路由 GET 方法
.apply(LoggerMiddleware)
.forRoutes({ path: 'cats', method: RequestMethod.GET })
}
}
- 异常过滤器(在特定范围捕获特定异常并处理),可作用于单个路由,整个控制器或全局:
// 程序需要抛出特定的类型错误
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN)
// 定义
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const request = ctx.getRequest<Request>()
const status = exception.getStatus()
response
.status(status)
.json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
})
}
}
// 使用,此时 ForbiddenException 错误就会被 HttpExceptionFilter 捕获进入 HttpExceptionFilter 处理流程
@Post()
@UseFilters(new HttpExceptionFilter())
async create() {
throw new ForbiddenException()
}
- 守卫:返回 boolean 值,会根据返回值决定是否继续执行后续声明周期:
// 声明时需要使用 @Injectable 装饰且实现 CanActivate 并返回 boolean 值
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
return validateRequest(context);
}
}
// 使用时装饰 controller、handler 或全局注册
@UseGuards(new AuthGuard())
async create() {
return 'This action adds a new cat'
}
- 管道(更侧重对参数的处理,可以理解为 controller 逻辑的一部分,更声明式):
@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
// 使用 id param 通过 UserByIdPipe 读取到 UserEntity
return userEntity
}
- 校验:参数类型校验,在使用 TypeScript 开发的程序中的运行时进行参数类型校验。
- 转化:参数类型的转化,或者由原始参数求取二级参数,供 controllers 使用:
我们再来简单的看下 Nest.js 对不同应用类型和不同 http 提供服务是怎样做适配的:
- 不同应用类型:Nest.js 支持 Http、GraphQL、Websocket 应用,在大部分情况下,在这些类型的应用中生命周期的功能是一致的,所以 Nest.js 提供了上下文类
ArgumentsHost
、ExecutionContext
,如使用 host.switchToRpc()
、host.switchToHttp()
来处理这一差异,保障生命周期函数的入参一致。 - 不同的 http 提供服务则是使用不同的适配器,Nest.js 的默认内核是 Express,但是官方提供了 FastifyAdapter 适配器用于切换到 Fastify。