Next.js v14 的 cookies()、header() 函数实现原理 —— AsyncLocalStorage

2,968 阅读6分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

如果说,有一个 Node.js API 是 Next.js 路由和我们日常开发用到的多个 API 的核心,你猜是哪个 API?

答案是 AsyncLocalStorage

这可不是一个新 API,早在 2020 年就进入了稳定阶段,在日常的开发中,也可放心使用。

本篇就为大家介绍下 AsyncLocalStorage 这个 API,此外本篇我还会写一个可运行的 Demo,用于展示 AsyncLocalStorage 如何在 Next.js 的 cookies()、headers() 等函数中发挥作用,帮助大家理解 cookies()、headers() 函数的实现原理。

  1. 本篇已收录到掘金专栏《Next.js 开发指北》

  2. 系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

欢迎围观朋友圈、加入低调务实优秀中国好青年前端社群,一个人走得快,一群人走得远。

Node.js AsyncLocalStorage 介绍

先让我们介绍下 AsyncLocalStorage 这个 API,简单的来说,这是一个解决异步操作中数据存储的 API。

像我们使用 Next.js、Express.js 等 Node.js 框架写路由处理程序时,一个请求中可能会连续嵌套调用多个函数:

app.get('/', async (req, res, next) => {
  const result = await one();
});

async function one() {
  setTimeout(() => {
    two()
  })
}

function two() {
  // ....
}

如果我们在请求时声明一个值(就比如用于监控的 traceId),如何保证深层次的函数如上图的 two() 函数准确的获取这个值,而且保证各个请求之间相互独立,不会获取错乱呢?毕竟我们还用了 async/await、setTimeout 等异步方式调用,如果直接声明为全局变量,很容易就获取错误。

一个简单的方法就是将参数透传。用伪代码表示如下:

app.get('/', async (req, res, next) => {
  const traceId = uuid();
  const result = await one(traceId);
});

async function one(traceId) {
  setTimeout(() => {
    two(traceId)
  })
}

function two(traceId) {
  console.log(traceId)
}

这样一层一层传递当然是可以的,就是不够优雅!

Node.js 直接提供了 AsyncLocalStorage 这个 API 用于处理异步操作中的数据存储问题,按照 Node.js 的说法,该 API 高性能且内存安全。而且于 Node.js v16 版本就已进入稳定阶段。所以可以放心使用。

AsyncLocalStorage 的用法也比较简单:

import { AsyncLocalStorage } from 'node:async_hooks';

const storage = new AsyncLocalStorage();
let id = 0;

function one() {
  storage.enterWith({
    traceId: id++
  });
  two()
}

function two() {
  setTimeout(() => {
    three()
  },1000)
}

function three() {
  const store = storage.getStore()
  console.log(store.traceId, id)
}

one();
one();
one();
one();
one();

在这个例子中,我们声明一个 AsyncLocalStorage 实例,调用 enterWith 方法存储值,调用 getStore 获取值。

输出的效果如下:

image.png

尽管我们用了 setTimeout,但每个请求都获取到了正确的值,而如果我们直接获取外层 id 变量,因为 setTimeout 的异步效果,每次打印的值都是 5。

除了用 enterWith,也可以使用 run 方法,Node.js 提供了官方示例代码:

import http from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

function logWithId(msg) {
  const id = asyncLocalStorage.getStore();
  console.log(`${id !== undefined ? id : '-'}:`, msg);
}

let idSeq = 0;
http.createServer((req, res) => {
  asyncLocalStorage.run(idSeq++, () => {
    logWithId('start');
    // Imagine any chain of async operations here
    setImmediate(() => {
      logWithId('finish');
      res.end();
    });
  });
}).listen(8080);

http.get('http://localhost:8080');
http.get('http://localhost:8080');
// Prints:
//   0: start
//   1: start
//   0: finish
//   1: finish

run 的第一个参数是 store,表示要存储的值,第二个参数是回调函数,store 只能在回调函数内访问,回调函数内创建的任何异步操作都可以访问该 store。

在这个例子中,我们使用 AsyncLocalStorage 构建了一个简单的 HTTP 请求 traceId,虽然发出了两条请求,但每条请求的 traceId 都是相互独立的,只能在各自的请求中获取到。

但是 AsyncLocalStorage 到底是怎么实现的呢?归根到底还是使用了底层的 API,拿到了异步函数的调用(AsyncResource,每次异步调用,V8都会创建一个对应的 AsyncResource),将 store 存储到这个异步资源上,所以在异步函数中调用也能正常获取到 store。

此外,使用 AsyncLocalStorage 会带来一定的性能损失,但相比它带来的收益,依然是十分值得使用的。

Next.js cookies() 介绍

说完 AsyncLocalStorage,我们说说 Next.js 的 cookies 函数,这是 Next.js 提供的用于获取请求 Cookie 的 API,使用方式如下:

import { cookies } from 'next/headers'
 
export default function Page() {
  const cookieStore = cookies()
  const theme = cookieStore.get('theme')
  return '...'
}

除了 cookies(),获取标头的做法也是类似的,只是改用 headers() 函数:

import { headers } from 'next/headers'
 
export default function Page() {
  const headersList = headers()
  const referer = headersList.get('referer')
 
  return <div>Referer: {referer}</div>
}

但是,在使用 cookies()、header() 的时候,有没有想过,为什么可以这样获取呢?为什么调用一下 cookies() 函数就可以获取到请求的 cookie,而不会出现错乱呢?为什么不采用透传 req 的方式来实现呢,就比如这样写:

export default function Page(req) {
  const headers = req.headers()
  const cookies = req.cookies() 
  return // ...
}

cookeis()、headers() 的背后到底是怎么实现的呢?

这就要说到 AsyncLocalStorage。为了演示 cookies 的工作原理,我们顺便使用 Express 手写一个 React SSR,那就让我们开始吧!

AsyncLocalStorage 实现 cookies()

新建 next-cookies项目目录,运行 npm init初始化项目。

运行以下命令安装用到的依赖项:

npm i tsx express react react-dom

其中:

  1. tsx 用于编译运行 jsx 文件(当然你也可以用 bun 或者其他工具替代)
  2. express 用于构建服务
  3. react、react-dom 用于书写 React 代码

新建 index.tsx,代码如下:

import express from "express";
import { AsyncLocalStorage } from 'node:async_hooks';
import { renderToPipeableStream } from 'react-dom/server';
import React from 'react';
import { User } from './user';

const cookiesStorage = new AsyncLocalStorage();

export function cookies() {
  return cookiesStorage.getStore();
}

function parseCookies(request) {
  const cookiesHeader = request.headers.cookie || '';
  if (!cookiesHeader) return {}
  return Object.fromEntries(
    cookiesHeader.split(';').map(cookie => {
      const [name, ...rest] = cookie.trim().split('=');
      return [name, rest.join('=')];
    })
  )
}

const app = express();

app.get("/:route(*)", async (req, res) => {
  const cookies = parseCookies(req);
  console.log(cookies)
  cookiesStorage.run(cookies, async () => {
    const { pipe } = renderToPipeableStream(<User />, {
      onShellReady() {
        res.setHeader('content-type', 'text/html');
        pipe(res);
      }
    });
  })
});

app.listen(3000, (err) => {
  if (err) return console.error(err);
  return console.log(`Server is listening on 3000`);
});

这段代码并不复杂,我们来详细解释一下作用。

当访问 /xxx的时候,首先调用 parseCookies 获取 req 中的 cookies 对象,当然我们也可以直接使用 cookies-parse 等中间件,但这里为了更直观的展示,我们手动读取了 cookies 标头并将其转为对象。

然后调用 cookiesStorage.run,将 parse 后的 cookies 作为 store 传入,这样我们就可以在回调函数中的任何地方获取到该 store。

而在回调函数中,我们调用 renderToPipeableStream 将 React 组件转为流的形式进行返回。

renderToPipeableStream 是标准的 React API, 将一个 React 组件树渲染为管道化(pipeable)的 Node.js 流,具体使用方式可以参考 React 官网,这里我们演示的是一个标准的 renderToPipeableStream 用法。

而在具体的 <User> 组件中,新建 user.tsx,代码如下:

import React from 'react';
import { cookies } from '.' 

export function User() {
  const cookiesStore = cookies()
  return (
    <html lang="zh">
      <body>
        <h3>Cookies:</h3>
        {JSON.stringify(cookiesStore, null, 2)}
      </body>
    </html>
  )
}

我们调用了 index.tsx 导出的 cookies 函数,而导出的 cookies 函数代码其实很简单:

export function cookies() {
  return cookiesStorage.getStore();
}

就是这样,我们调用 cookies() 获取了请求的 cookies 对象。归根到底还是因为调用 cookies() 函数的时候还是在 cookiesStorage.run 的回调函数中。

修改 package.json,添加脚本命令:

{
  "scripts": {
    "start": "tsx watch ./index.tsx"
  }
}

最后运行 npm start,效果如下:

image.png

是不是跟我们在 Next.js 使用 cookies()、headers() 的方式很类似了?

  1. 功能实现:Next.js Cookies 函数
  2. 仓库源码:github.com/mqyqingfeng…
  3. 下载代码:git clone -b nextjs-cookies git@github.com:mqyqingfeng/next-app-demo.git

PS:学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

参考链接

  1. www.youtube.com/watch?v=Jej…
  2. juejin.cn/post/723362…