这些年用Next.js做服务端渲染和全栈应用,我最大的一个感触是:中间件(Middleware)这个能力,看起来只是“在请求到达页面之前跑一段代码”,但用好了,它能把鉴权、A/B测试、国际化跳转、流量治理这些横切逻辑从各个业务代码里抽出来,统一收敛到入口层。而与此同时,很多开发者在处理这类入口逻辑时会撞上一个隐形的天花板:只要业务涉及方向、周期、角度、波形,就绕不开三角函数。无论是做地理位置就近调度、活动流量的平滑放量,还是生成某个周期性的模拟数据,三角函数公式都能派上决定性用场。这篇实战指南,就把这两件事放到一起讲清楚:一边是Next.js中间件的完整实战拆解,一边是三角函数公式的系统归纳,并且用一个真实场景把两者串起来。
这篇内容适合正在做Next.js全栈或服务端渲染的开发者,也适合那些数学知识还给了老师、但在工程里突然需要捡回来的朋友。前面三分之二的篇幅聚焦中间件的原理、配置、实战场景和排查方法,后面三分之一集中归纳三角函数公式,从基础恒等式到和差化积一次讲透。我会把我实际项目中踩过的坑、验证过的写法、以及调试经验一并写进去。
1. 为什么中间件和三角函数会出现在同一篇文章里
1.1 中间件到底解决了什么问题
先说个真实经历。早年间做一个多区域部署的电商站点,最初所有区域共用一套服务端逻辑,后来业务膨胀,发现每次加一个新的市场规则,都得跑到各个页面里改getServerSideProps或者接口层,把代码改得到处都是。比如某个区域需要强制跳转到当地语言首页,某个渠道需要走不同的活动策略,某次大促要对一部分流量做新版本实验。这些逻辑本身跟页面渲染没有关系,却散落在各处,导致维护成本直线上升。
后来引入中间件,思路就清晰了。中间件在Next.js里指的是在请求匹配页面或接口之前、由Edge Runtime执行的一段代码,它能在响应返回给客户端之前完成改写、重定向、鉴权甚至直接返回内容。这意味着,你可以把“这个请求该走哪个页面版本”“这个用户是否有权限访问”“这个区域应该落到哪个域名下”这类前置判断统一收敛到一处。
1.2 三角函数在这个链路里的位置
那三角函数是哪里冒出来的?举一个具体的例子。某国际化项目需要在多个区域的首页之间做流量切换,按照用户地理位置计算“所在地到某服务器节点的方向角”,或者按照时间段做平滑放量——比如从0%逐步提升到100%,如果直接用线性函数,流量曲线会有明显的折点,想要平滑过渡就需要正弦函数来控制变化率;再比如要生成某个周期性报表的模拟数据、计算某个营销活动的振荡周期,这些都需要三角函数。中间件负责执行逻辑,三角函数负责把数学关系算对,两者在这个场景里自然相遇。
这也是我把它们放在同一篇里的原因:中间件是“手”,告诉你代码写在哪一层、怎么写;三角函数是“脑”,告诉你某些业务关系该怎么建模。这两块知识,在实际的Next.js实战里经常是一起被需要的。
2. Next.js中间件基础与环境准备
2.1 中间件运行环境:Edge Runtime的边界
在动手写代码之前,必须先弄清楚一个关键概念:中间件跑在Edge Runtime,而不是Node.js Runtime。它基于Web标准API实现,这意味着很多Node.js的原生模块(比如fs、path)在里面不能用,数据库驱动也别指望直接连。它更像一个轻量化的请求拦截层,而不是一个完整的服务端环境。
我见过不少同事第一次写中间件时,顺手就import fs from 'fs',然后本地跑得好好的,部署上去就报错。原因是本地开发时Next.js做了兼容处理,但生产环境的Edge Runtime不会给你Node.js的能力。所以一个核心心法就是:中间件只做轻量判断和请求改写,把重逻辑留给API路由或者Server Actions。
2.2 创建中间件文件与基础骨架
在Next.js项目根目录下新建middleware.ts(或者src/middleware.ts,取决于你的目录结构),一个最基础的中间件长这样:
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { // 这里写你的判断逻辑 return NextResponse.next(); } export const config = { matcher: ['/dashboard/:path*', '/admin/:path*'], };这段代码的关键点有两个。第一,导出的middleware函数会被Next.js自动识别,每个匹配的请求进来时都会执行它。第二,config.matcher用来指定哪些路径需要经过中间件,这个配置直接影响性能,因为不在匹配范围内的请求完全不会触发中间件逻辑。
NextResponse这个类提供了四种最常见的返回方式:
NextResponse.next():请求继续往下走,相当于什么都不做。NextResponse.redirect(url):重定向到另一个地址。NextResponse.rewrite(url):改写请求目标,但URL地址栏不变。NextResponse.json(data):直接返回JSON响应,适合做API网关拦截。
2.3 matcher匹配规则的写法与性能优化
matcher支持通配符语法,和路径匹配有关。常见写法:
export const config = { matcher: [ '/dashboard/:path*', // 匹配 /dashboard 下的所有路径 '/admin/:path*', '/((?!api|_next/static|favicon.ico).*)', // 排除 API 和静态资源 ], };第二种写法里用到了正则负向先行断言,意思是“除了api、_next/static、favicon.ico之外的路径都匹配”。这个技巧非常实用,因为如果你不排除静态资源,中间件会在每个图片、脚本、样式文件请求时都被执行,白白增加延迟。
性能方面的建议是:matcher越具体越好,能用/dashboard/:path*就用,不要图省事写/(.*)把所有请求都拦下来。中间件虽然快,也不是零成本,在边缘节点上每多一次执行,用户感知的TTFB就会多一点延迟。
3. 中间件核心实战场景拆解
3.1 鉴权与登录态校验
最常见的中间件用途就是鉴权。在没有中间件的时代,每个受保护页面都要在getServerSideProps里取cookie、验token、跳登录页,代码重复且容易漏。有了中间件,可以在请求入口统一处理。
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const protectedPaths = ['/dashboard', '/admin', '/settings']; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const token = request.cookies.get('session_token')?.value; const isProtected = protectedPaths.some(path => pathname.startsWith(path)); if (isProtected && !token) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('redirect', pathname); return NextResponse.redirect(loginUrl); } return NextResponse.next(); } export const config = { matcher: ['/dashboard/:path*', '/admin/:path*', '/settings/:path*'], };这里有几个细节值得注意。第一,request.cookies.get()拿到的cookie值是字符串,如果存的是JWT,中间件里可以做简单的格式校验,但完整的JWT验签不建议在这里做,因为Edge Runtime对加密库的支持有限。第二,重定向时带上redirect参数,登录成功后可以把用户引导回原页面,这个体验细节能减少很多“登录成功后又迷路”的投诉。
3.2 A/B实验与流量分配
A/B测试是中间件的一个高频应用场景。常见需求是:根据用户ID或随机数,把不同比例的流量导向不同页面版本,同时保证同一个用户始终看到同一个版本。
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const variant = request.cookies.get('ab_variant')?.value; if (!variant) { const userId = request.cookies.get('user_id')?.value || 'anonymous'; const hash = simpleHash(userId + 'landing-page'); const assignedVariant = hash % 100 < 50 ? 'A' : 'B'; const response = NextResponse.next(); response.cookies.set('ab_variant', assignedVariant, { path: '/', maxAge: 60 * 60 * 24, }); return response; } const url = request.nextUrl.clone(); if (variant === 'B') { url.pathname = '/landing-v2'; return NextResponse.rewrite(url); } return NextResponse.next(); } function simpleHash(input: string): number { let hash = 0; for (let i = 0; i < input.length; i++) { hash = (hash << 5) - hash + input.charCodeAt(i); hash |= 0; } return Math.abs(hash); }使用rewrite而不是redirect的用意需要解释一下:rewrite会在服务端把请求映射到/landing-v2,但浏览器URL不会变化,这样用户看到的是同一个地址,但内容来自不同版本,对实验的干扰最小。redirect则会改变地址栏URL,导致URL不一致、分享链接出现偏差,一些A/B测试工具会因此统计不准。
流量分配时取模的写法要小心:hash % 100得到0到99的值,小于50走A,否则走B,就是50%分流。要调整比例,改阈值就行。这个逻辑简单可靠,唯一需要注意的是hash函数要足够稳定,保证同一用户多次请求得到相同结果。
3.3 地域识别、语言跳转与URL规范化
多语言站点的语言跳转,是另一个典型场景。用户来自不同地区,应该自动看到对应语言的版本。中间件可以从request.geo拿到大致的地区信息(注意:本地开发时geo通常是空的,部署到边缘平台才有值),再配合Accept-Language请求头做兜底。
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const LOCALE_MAP: Record<string, string> = { 'US': 'en-US', 'GB': 'en-GB', 'JP': 'ja-JP', 'KR': 'ko-KR', 'CN': 'zh-CN', }; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // 如果路径已经是语言前缀,不做处理 if (/^\/(en|ja|ko|zh)(\/|$)/.test(pathname)) { return NextResponse.next(); } const country = request.geo?.country || ''; const locale = LOCALE_MAP[country] || 'en-US'; const url = request.nextUrl.clone(); url.pathname = `/${locale}${pathname}`; return NextResponse.rewrite(url); }这里用rewrite而不是redirect,是为了保留一个干净的URL。实际项目中,你可能会看到两种选择:有的团队喜欢URL带语言前缀做SEO,那就用rewrite让用户在地址栏看到简洁路径,但搜索引擎爬虫抓取时能看到本地化内容;有的团队喜欢让地址栏直接显示/en/...,那就用redirect。这个取舍没有标准答案,看SEO策略和产品预期。
关于request.geo,需要说明的是:它基于请求IP推断,在本地开发环境通常拿不到准确值,部署到支持Edge Runtime的平台后才会自动填充。所以本地调试时,可以用查询参数或者x-test-geo请求头模拟,我习惯在开发环境加一个兜底逻辑:
const country = request.headers.get('x-test-geo') || request.geo?.country || '';这样测试的时候设置请求头就能模拟不同地区,方便很多。
除了语言跳转,URL规范化也经常交给中间件。最常见的场景是统一HTTPS、去除末尾斜杠、强制小写路径等。举个例子:
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const { pathname, search } = request.nextUrl; // 强制 HTTPS if (request.headers.get('x-forwarded-proto') !== 'https') { const httpsUrl = `https://${request.headers.get('host')}${pathname}${search}`; return NextResponse.redirect(httpsUrl); } // 去除末尾斜杠 if (pathname.length > 1 && pathname.endsWith('/')) { const normalizedUrl = request.nextUrl.clone(); normalizedUrl.pathname = pathname.replace(/\/+$/, ''); return NextResponse.redirect(normalizedUrl); } return NextResponse.next(); } export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'], };这类规范化逻辑放在中间件里非常划算,因为它在一切页面渲染之前执行,后端不用再为“用户输入了错误的URL格式”做各种兼容处理。我以前写后端时,经常要处理/about/和/about两个路径返回不同状态码的问题,有了中间件,这个脏活就被前置了。
3.4 请求头改写与安全响应头注入
中间件还能统一改写请求头、注入响应头。这个能力在做安全加固时很好用。比如你希望所有页面请求都带上X-Robots-Tag来控制搜索引擎抓取,或者统一注入X-Frame-Options防止点击劫持,没必要在Nginx配置里改,直接在中间件里统一加就行。
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const response = NextResponse.next(); // 注入安全响应头 response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); // 非首页请求,禁止搜索引擎收录 if (request.nextUrl.pathname !== '/') { response.headers.set('X-Robots-Tag', 'noindex'); } return response; } export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'], };这里比较容易被忽略的一点是:如果你使用了NextResponse.rewrite(),返回的这个响应对象里原本设置的headers会被保留吗?答案是会。因为rewrite返回的也是NextResponse实例,你可以在链式调用中持续追加headers。但要注意,一旦你执行了return NextResponse.redirect(...)并在此之前已经设置了headers,那些headers会随着重定向响应一起返回给客户端,这通常是符合预期的,因为重定向响应本身也可以携带安全头。
还有一个坑是命名冲突:有些请求头在Edge Runtime里是只读的,比如content-length、host,你在中间件里设置它们不会生效,甚至可能报错。我建议只设置自定义头和明确允许改写的标准头,不要试图去改那些由运行时管理的头部。
4. 中间件性能、调试以及部署中的排查经验
4.1 Edge Runtime的限制清单
写中间件时,最大的心智负担是“什么东西不能用”。我用一张表格整理一下常见能力边界:
| 能力 | Edge Runtime支持情况 | 说明 |
|---|---|---|
| Fetch API | 支持 | 可以调用远程HTTP接口 |
| Web Crypto API | 支持 | 支持crypto.subtle,可以用于签名校验 |
| Node.js原生模块 | 不支持 | fs、path、http等都不能用 |
| 数据库直连 | 不支持 | 不能直接连接数据库或使用ORM |
| 读取环境变量 | 支持 | 通过process.env读取,但仅限非敏感配置 |
| 原生Buffer | 不支持 | 需要用Uint8Array等替代 |
| 流式处理 | 有限支持 | 可以读取请求体,但复杂流处理不建议 |
如果你在中间件里需要读取请求体,可以用request.json()或request.text()。但这有一个代价:读取请求体之后,request.body会被消耗掉,下游的路由或API再想读一次就读不到了。所以如果中间件只是做简单判断,尽量不要读body;如果确实需要读,读完后需要用request.clone()来做备份。这个坑我在项目中踩过一次,排查了很久才发现是中间件把body消费掉了,导致下游接口拿不到参数。
4.2 冷启动延迟与中间件体积控制
Edge函数有个特征:代码体积越小,冷启动越快。中间件里尽量只引入必要的工具函数,不要import整个第三方库。很多人习惯把lodash这种大工具库带进中间件,结果边缘函数的冷启动时间直线上升。我在一个项目里曾经把moment-timezone引入中间件做时区判断,部署后整体响应时间多了将近100ms,后来换成手写时区偏移映射表,立刻恢复。
建议是:中间件代码保持在几十KB以内。如果逻辑复杂,宁可拆成API路由去处理,也不要全堆在中间件里。还有一个小技巧:把静态映射表(比如语言代码到Locale的映射、国家代码到区域的映射)直接写在中间件文件里,做tree-shaking时也能保留最少的运行时负载。
4.3 本地调试与线上排查方法
中间件的调试,最直接的方式就是在本地开发模式下跑起来,然后用浏览器的无痕模式测试。本地开发时,next dev会为中间件打日志,但日志输出跟普通服务端日志混在一起,我习惯在中间件里加上针对性的日志,线上排查时可以临时在某个测试路径下开启调试模式:
// middleware.ts export function middleware(request: NextRequest) { const isDebug = request.headers.get('x-debug') === '1'; if (isDebug) { console.log('middleware path:', request.nextUrl.pathname); console.log('geo:', JSON.stringify(request.geo)); console.log('cookies:', request.cookies.getAll().map(c => c.name)); } // ... }线上排查的话,在支持Edge Runtime的部署平台上,可以通过平台自带的面板查看边缘函数调用日志。重点看两个指标:耗时和内存。中间件的预期耗时应该是个位数毫秒到十毫秒出头,如果超过50ms,说明你放了重逻辑在里面,需要考虑拆分。
我整理过一份中间件排查速查表,供参考:
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 所有路径都经过中间件 | matcher配置了/(.*) | 检查config.matcher,排除_next/static等路径 |
| 重定向循环 | 中间件里重定向条件恒成立 | 在重定向前加路径白名单判断 |
| Cookie设置不生效 | 设置时缺少path属性 | 设置cookie时显式加path: '/' |
| 环境变量读不到 | 变量名前缀不符合运行时要求 | 环境变量不要加NEXT_PUBLIC_前缀,使用服务端前缀 |
| 所有请求都返回500 | 代码里使用了不支持的Node.js API | 查看部署日志,定位具体报错,换成Web标准API |
| 请求body为空 | 中间件读取并消费了body | 读取前先request.clone() |
5. 三角函数公式全归纳
前面聊了这么多Next.js中间件的实战内容,现在切入第二个主题。之所以把三角函数部分放到后半段,是因为很多工程场景下,你先把主流程打通了,才会遇到“这里需要算个角度、模拟个波形”的需求。三角函数公式,就是那个当你需要它时,手边最好有一份完整清单的知识库。
5.1 基础关系与同角恒等式
先打好地基。三角函数里最基础也最重要的,是同角三角函数的平方关系:
- $$ \sin^2 \theta + \cos^2 \theta = 1 $$
- $$ 1 + \tan^2 \theta = \sec^2 \theta $$
- $$ 1 + \cot^2 \theta = \csc^2 \theta $$
这三个公式是同角恒等式的核心,几乎所有的化简题都会用到它们。工程上的典型场景是:你只知道某个角度的正弦值,需要快速算出余弦值,这时候直接用Math.sqrt(1 - sin * sin)就可以,但要注意正负号取决于角度所在的象限。
商数关系也是日常用得很多的:
$$ \tan \theta = \frac{\sin \theta}{\cos \theta} $$
$$ \cot \theta = \frac{\cos \theta}{\sin \theta} $$
倒数的关系则显得更“琐碎”:
$$ \csc \theta = \frac{1}{\sin \theta},\quad \sec \theta = \frac{1}{\cos \theta},\quad \cot \theta = \frac{1}{\tan \theta} $$
编码的时候,Math.sin()、Math.cos()、Math.tan()直接可用,但sec、csc、cot在JavaScript标准库里没有,碰到需要的时候就用倒数关系转换:
const sec = (angleRad: number) => 1 / Math.cos(angleRad); const csc = (angleRad: number) => 1 / Math.sin(angleRad); const cot = (angleRad: number) => 1 / Math.tan(angleRad);5.2 两角和与差公式
两角和与差公式是三角函数变换的发动机。它们的地位,类似于代数里的分配律。公式如下:
$$ \sin(\alpha \pm \beta) = \sin \alpha \cos \beta \pm \cos \alpha \sin \beta $$
$$ \cos(\alpha \pm \beta) = \cos \alpha \cos \beta \mp \sin \alpha \sin \beta $$
$$ \tan(\alpha \pm \beta) = \frac{\tan \alpha \pm \tan \beta}{1 \mp \tan \alpha \tan \beta} $$
记忆它们有个小窍门:正弦后加减号不变,余弦后加减号变号。也就是“sin保持符号,cos反转符号”。
工程上的典型应用是计算旋转后的坐标。假设一个点原来的角度是α,你希望它绕原点旋转β角,那么新的坐标可以用和角公式直接展开:
// 旋转一个点 (x, y) 绕原点旋转 beta 弧度 function rotatePoint(x: number, y: number, beta: number) { const cosB = Math.cos(beta); const sinB = Math.sin(beta); return { x: x * cosB - y * sinB, y: x * sinB + y * cosB, }; }这段代码在游戏开发、图形编辑、数据可视化里非常常见。如果你能熟练使用和角公式,就会明白为什么不直接用sqrt(x*x + y*y)算半径再加角度——因为三角函数展开式给了你一个不需要求角度就能完成旋转的路径,计算更快,数值也更稳定。
5.3 倍角公式与降幂公式
二倍角公式由和角公式直接推出来,工程实用性极高:
$$ \sin 2\theta = 2 \sin \theta \cos \theta $$
$$ \cos 2\theta = \cos^2 \theta - \sin^2 \theta = 2\cos^2 \theta - 1 = 1 - 2\sin^2 \theta $$
$$ \tan 2\theta = \frac{2 \tan \theta}{1 - \tan^2 \theta} $$
这里要特别重视cos 2θ的三种等价写法,因为降幂公式就是从它来的:
$$ \sin^2 \theta = \frac{1 - \cos 2\theta}{2} $$
$$ \cos^2 \theta = \frac{1 + \cos 2\theta}{2} $$
降幂公式的常见用途是把二次项转成一次项,方便积分、化简或者在数据平滑场景中计算移动平均值。我在做流量波形模拟时,经常需要生成一组平滑的周期性信号,如果直接叠加速度变化,会出现跳变,改用sin²或者cos²做加权,曲线会自然平滑很多。
三倍角公式应用场景相对少一些,但偶尔会在信号处理和3D旋转里碰到,一并列出:
$$ \sin 3\theta = 3\sin \theta - 4\sin^3 \theta $$
$$ \cos 3\theta = 4\cos^3 \theta - 3\cos \theta $$
5.4 半角公式
半角公式由降幂公式演变而来,核心是把θ/2的三角函数用θ的三角函数表示:
$$ \sin \frac{\theta}{2} = \pm \sqrt{\frac{1 - \cos \theta}{2}} $$
$$ \cos \frac{\theta}{2} = \pm \sqrt{\frac{1 + \cos \theta}{2}} $$
$$ \tan \frac{\theta}{2} = \frac{1 - \cos \theta}{\sin \theta} = \frac{\sin \theta}{1 + \cos \theta} $$
正负号的选取,取决于θ/2所在的象限。工程上用半角公式的场景通常是:你已经知道了某个角度的余弦值(可能通过余弦定理算出来的),但需要求它的半角正弦或余弦。比如在地理围栏计算里,已知两点间的圆心角,需要求半圆心角对应的弦长,这时半角公式就直接上场。
5.5 积化和差与和差化积公式
这两个家族公式在工程里像是“信号处理的翻译器”,它们可以在两个三角函数的乘积与和差之间互相转换。
积化和差:
$$ \sin \alpha \cos \beta = \frac{1}{2}[\sin(\alpha + \beta) + \sin(\alpha - \beta)] $$
$$ \cos \alpha \sin \beta = \frac{1}{2}[\sin(\alpha + \beta) - \sin(\alpha - \beta)] $$
$$ \cos \alpha \cos \beta = \frac{1}{2}[\cos(\alpha + \beta) + \cos(\alpha - \beta)] $$
$$ \sin \alpha \sin \beta = \frac{1}{2}[\cos(\alpha - \beta) - \cos(\alpha + \beta)] $$
和差化积:
$$ \sin A + \sin B = 2\sin\frac{A + B}{2} \cos\frac{A - B}{2} $$
$$ \sin A - \sin B = 2\cos\frac{A + B}{2} \sin\frac{A - B}{2} $$
$$ \cos A + \cos B = 2\cos\frac{A + B}{2} \cos\frac{A - B}{2} $$
$$ \cos A - \cos B = -2\sin\frac{A + B}{2} \sin\frac{A - B}{2} $$
这些公式的实际工程价值体现在音频处理、振动分析、信号叠加等场景。比如你要在Web端做一个简单的示波器效果,需要叠加两个不同频率的正弦波,要分析叠加后的包络线,用积化和差就能推导出拍频的周期。
代码示例:把两个正弦信号相乘转换成和差形式。
// 信号相乘,用积化和差分析 function multiplySignals( a1: number, f1: number, a2: number, f2: number, t: number ) { // 直接用积化和差展开 const left = Math.sin(2 * Math.PI * (f1 + f2) * t); const right = Math.sin(2 * Math.PI * (f1 - f2) * t); return (a1 * a2 / 2) * (left + right); }5.6 万能公式与辅助角公式
万能公式的价值在于:它把sin θ、cos θ和tan θ统一用tan(θ/2)来表示,在某些积分和方程求解中可以大幅简化计算。
$$ \sin \theta = \frac{2\tan\frac{\theta}{2}}{1 + \tan^2\frac{\theta}{2}} $$
$$ \cos \theta = \frac{1 - \tan^2\frac{\theta}{2}}{1 + \tan^2\frac{\theta}{2}} $$
$$ \tan \theta = \frac{2\tan\frac{\theta}{2}}{1 - \tan^2\frac{\theta}{2}} $$
辅助角公式更像是一个“合并同类项”的工具。它把一个形如a sin θ + b cos θ的表达式合并成一个单一的正弦函数:
$$ a\sin \theta + b\cos \theta = \sqrt{a^2 + b^2} \sin(\theta + \varphi) $$
其中,φ满足:
$$ \cos \varphi = \frac{a}{\sqrt{a^2 + b^2}},\quad \sin \varphi = \frac{b}{\sqrt{a^2 + b^2}} $$
辅助角公式在工程上最常见的使用场景是:把简谐运动的叠加写成单一正弦波。比如你做动画缓动,想要一个既能回摆又能平移的运动轨迹,把所有分量合并成一个正弦,就能更直观地控制振幅和相位:
// 将 a*sin(x) + b*cos(x) 合并为 A*sin(x + phi) function combineSine( a: number, b: number ): { amplitude: number; phase: number } { const amplitude = Math.sqrt(a * a + b * b); const phase = Math.atan2(b, a); return { amplitude, phase }; }5.7 反三角函数与角度坐标转换
反三角函数解决的是“已知三角函数值,求角度”的问题。工程代码里最常用的是Math.atan2(y, x),它和Math.atan(y / x)的区别在于,atan2能根据x和y的符号确定角度所在象限,返回范围是[-π, π]。这是做极坐标转换时的首选函数。
比如给定一个经纬度坐标,要计算两个地点之间的方向角,就离不开atan2:
function bearing( lat1: number, lon1: number, lat2: number, lon2: number ): number { const toRad = (deg: number) => (deg * Math.PI) / 180; const toDeg = (rad: number) => (rad * 180) / Math.PI; const φ1 = toRad(lat1); const φ2 = toRad(lat2); const Δλ = toRad(lon2 - lon1); const y = Math.sin(Δλ) * Math.cos(φ2); const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); return (toDeg(Math.atan2(y, x)) + 360) % 360; }这种计算在“根据用户地理位置就近分配节点”的业务里很常见。虽然中间件里不一定每次都算这个大公式,但当你需要在边缘做地理位置相关逻辑时,这类三角函数知识就是刚需。
反三角函数取值范围也值得单独记住:
Math.asin(x)返回[-π/2, π/2]Math.acos(x)返回[0, π]Math.atan(x)返回(-π/2, π/2)Math.atan2(y, x)返回[-π, π]
这意味着,如果你的业务逻辑对角度有“必须落在哪个象限”的预期,直接用asin或acos的返回值而不做象限修正,很可能得到错误结果。建议在工程代码里用atan2统一处理角度计算,除非你非常确定输入值所在的象限。
6. 实战串联:中间件里的正弦波平滑放量
说了这么多公式,回到中间件的战场,用一个完整的实战例子来演示“三角函数到底怎么参与中间件逻辑”。
6.1 业务场景
假设你运营一个面向多个区域的站点,某天要上线一个新功能,计划在24小时内把流量从0%逐步放大到100%。如果直接在中间件里写“按时间线性放量”,每个时间段内的流量增长速度恒定,但这种直线策略在切换瞬间容易造成负载陡增。更平滑的做法是让放量速度先慢后快再变慢,整体呈S形曲线,而正弦函数从0到π/2这段的递增曲线恰好就是“先慢后快”的形状,如果配合半角或者余弦变换,可以得到更完整的S形。
6.2 用正弦函数控制放量比例
核心思路是:把24小时映射到[0, π/2]区间,用sin(θ)作为放量比例函数。第t小时的放量比例为:
$$ \text{ratio}(t) = \sin\left(\frac{t}{24} \cdot \frac{\pi}{2}\right) $$
当t = 0时,ratio = 0;当t = 24时,ratio = 1。曲线平滑,没有直线拐点。
但这里有一个问题:正弦函数在0附近增长很慢,在π/4附近最快,这种“先慢后快”的曲线对发布来说意味着前期流量太小,验证效果不明显。我们可以改造成半周期的余弦升降,也就是把区间映射到[0, π],用(1 - cosθ) / 2得到一个从0到1的S形增长:
$$ \text{ratio}(t) = \frac{1 - \cos\left(\frac{t}{24} \cdot \pi\right)}{2} $$
这个公式的曲线是经典的平滑S形,0点和24点时的增速都是0,中间点增速最快,视觉上比直线或者纯正弦都舒服得多。
6.3 中间件里的实现
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const RELEASE_START_TIME = new Date('2024-06-01T00:00:00Z').getTime(); const RELEASE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 小时 const NEW_VERSION_PERCENT_MIN = 0; const NEW_VERSION_PERCENT_MAX = 100; export function middleware(request: NextRequest) { const now = Date.now(); const elapsed = now - RELEASE_START_TIME; // 放量还没开始,全部走旧版本 if (elapsed <= 0) { return NextResponse.next(); } // 放量已结束,全部走新版本 if (elapsed >= RELEASE_DURATION_MS) { return routeToNewVersion(request); } // 计算放量比例,使用 (1 - cos) / 2 的 S 形曲线 const progress = elapsed / RELEASE_DURATION_MS; // 0 到 1 const ratio = (1 - Math.cos(progress * Math.PI)) / 2; const newVersionPercent = NEW_VERSION_PERCENT_MIN + ratio * (NEW_VERSION_PERCENT_MAX - NEW_VERSION_PERCENT_MIN); // 按用户ID哈希,保证同一个用户在一段时间内始终看到同一版本 const userId = request.cookies.get('user_id')?.value || 'anonymous'; const hash = simpleHash(userId + 'new-feature'); const shouldGetNew = hash % 100 < newVersionPercent; if (shouldGetNew) { return routeToNewVersion(request); } return NextResponse.next(); } function routeToNewVersion(request: NextRequest) { const url = request.nextUrl.clone(); url.pathname = '/new-experience'; return NextResponse.rewrite(url); } function simpleHash(input: string): number { let hash = 0; for (let i = 0; i < input.length; i++) { hash = (hash << 5) - hash + input.charCodeAt(i); hash |= 0; } return Math.abs(hash); } export const config = { matcher: ['/'], // 假设只作用在首页 };这个例子把前面讲的几个关键点都串起来了:中间件的matcher配置、rewrite做内部版本切换、cookie做用户身份稳定、哈希取模做流量分配,再加一个三角函数的S形平滑放量公式。你在实际项目中如果不需要S形曲线,直接换成线性ratio = progress就行;如果需要更陡峭的放量节奏,可以调整余弦频率,比如用[0, 2π/3]区间,让放量在更短时间内完成到某个比例。
这里我再说一个我踩过的坑:放量比例和时间起点一定要用“服务端统一时间”,不要在中间件里依赖客户端传来的时间。曾经有项目用request.headers.get('date')或者客户端参数做时间计算,结果用户一改本地时间就提前触发了新版本,排查了很久才发现是时间源没统一。正确做法是用服务器当前时间,也就是Date.now(),或者由边缘平台注入的标准时间头部来做时间基准。
7. 写在最后的几点经验
做中间件实战,我的几条核心体会是这样。第一,中间件的最佳位置永远是“轻判断、重转发”,任何需要重计算的任务都不要往这里塞,它应该像高速公路的闸口,检查车牌、放行或引导,而不是把车开进维修车间做大修。第二,善用matcher做精确控制,这不仅是性能优化,也是逻辑清晰度的保障——只有明确知道了哪些路径会走到中间件,你才敢在里面放心地做全局逻辑而不会误伤。第三,Cookie和rewrite的组合拳几乎能覆盖所有“按用户分流”的场景,哈希取模可以保证稳定性,rewrite可以保持URL统一,这在A/B测试和灰度发布中极其好用。
三角函数的部分,我的建议是不要死记硬背所有公式,而是要建立“什么时候该查哪个公式”的索引感。工程代码中最常用的是:sin² + cos² = 1、和角公式、二倍角公式、辅助角公式、atan2。这五个点覆盖了绝大多数跟角度、波形、旋转、周期相关的需求。把这份公式表存起来,用的时候翻出来对照,比背得滚瓜烂熟但在实际场景里不知道用哪个更靠谱。
最后再分享一个小技巧:写中间件时,如果你发现某个逻辑在本地开发环境跑得好好的,但部署后行为不一样,优先检查三个东西——request.geo是否为空、环境变量是否能读到、以及你用的API在Edge Runtime里是否真的存在。我遇到过的几乎所有中间件线上诡异问题,最后都归到这三个原因里。把这三个变量排查完,大部分问题都能定位到。