MoreRSS

site iconWei Jun修改

前端、全栈、AI、出海,运营《独立开发者出海周刊》。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Wei Jun的 RSS 预览

使用 CloudFlare 和 Resend 快速实现网站邮件订阅(newsletter)功能

2025-03-20 08:00:00

看多了英文网站,你会发现大多数网站都提供了 newsletter 订阅功能,也就是邮件订阅。因为因为海外用户其实都还习惯查看邮箱,所以如果我们的网站有邮件订阅功能,只要有用户订阅了邮件,我们就可以离用户更近一些。

当我知道利用 CloudFlare 和 Resend 可以低成本开发邮件订阅功能的时候,我就决定给我的自用Next.js 模板补充一下这个功能。

根据本文的步骤,你可以10分钟完成整个接入流程。不过需要提醒的是,本文教学的是接入的基础步骤,如果你的产品用户量大、安全需求高,你需要在此基础上,结合自己的业务添加额外的防范措施,这一点也会在文末提出一点我的看法。

什么是 CloudFlare 以及使用步骤

Cloudflare 是一家提供内容分发网络(CDN)、DDoS 防护、安全服务和边缘计算解决方案的全球性公司,帮助网站提高性能、安全性和可靠性。因为其慷慨的免费服务而被称为“赛博菩萨”。

本文的流程里,我们首先需要把域名放在 CloudFlare 解析

cloudflare dns

然后打开邮件功能

cloudflare email

创建邮箱转发

cloudflare email

继续

cloudflare email

根据提示自动添加 DNS 记录,

cloudflare email

完成后如图

cloudflare email

现在发送到 [email protected] 的邮件就会被 CloudFlare 转发到我指定的邮箱了。

什么是 Resend 以及使用步骤

Resend 是一个现代化的电子邮件 API 平台,使开发者能够轻松地将高质量的电子邮件功能集成到应用中,提供可靠的邮件发送、跟踪和分析服务。

注册地址:https://resend.com/

这里跳过注册步骤。

进入 Resend 后台,先添加域名

resend domain

输入要添加的域名后,直接点击「Sign in to CloudFlare」按钮,会自动添加 DNS 记录。

resend domain

现在前3个记录已经自动添加了,还需要我们手动添加 _dmarc 记录。

resend domain

回到 CloudFlare DNS,添加 _dmarc 记录,值为 v=DMARC1; p= quarantine;

cloudflare dns

如果 Resend 很久了还是现实 pending 状态,不要担心,这一步总是要等很久,先继续做下面的步骤

resend domain

先来创建 API Key,权限选择 Full Access,创建完成后,复制下 API Key

resend api key

resend api key

再打开 Audience,复制 Audience ID

resend api key

现在打开项目,在 .env 文件里添加 3 个环境变量

RESEND_API_KEY=
ADMIN_EMAIL=
RESEND_AUDIENCE_ID=

其中,ADMIN_EMAIL 是你的 Resend 账户邮箱。

代码实现

本节只提供实现思路,并留下完整代码的 GitHub 地址,需要完整代码可以自取。

前端订阅表单

首先,我们需要一个用户友好的订阅表单。以下是核心实现:

// components/footer/Newsletter.tsx
// 完整代码地址:https://github.com/weijunext/nextjs-15-starter/blob/main/components/footer/Newsletter.tsx

"use client";

export function Newsletter() {
  // 状态管理:邮箱、订阅状态和错误信息
  const [email, setEmail] = useState("");
  const [subscribeStatus, setSubscribeStatus] = useState("idle");
  const [errorMessage, setErrorMessage] = useState("");

  const handleSubscribe = async (e) => {
    try {
      // 设置加载状态
      setSubscribeStatus("loading");
      
      // API调用发送订阅请求
      const response = await fetch("/api/newsletter", {
        method: "POST",
        body: JSON.stringify({ email }),
        // 设置headers...
      });

      // 处理响应...

      // 5秒后重置状态...
    } catch (error) {
      // 错误处理...

      // 5秒后重置状态...
    }
  };

  return (
    <div>
      <form onSubmit={handleSubscribe}>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          disabled={subscribeStatus === "loading"}
        />
        <button disabled={subscribeStatus === "loading"}>
          {subscribeStatus === "loading" ? "订阅中..." : "订阅"}
        </button>
        
        {/* 状态反馈信息 */}
        {subscribeStatus === "success" && <p>订阅成功!</p>}
        {subscribeStatus === "error" && <p>{errorMessage}</p>}
      </form>
    </div>
  );
}

这个组件需要实现:

  • 邮箱输入和提交
  • 订阅成功或者失败的状态提示
  • 提示语可自动关闭

邮箱验证

为了确保邮箱地址的有效性,我实现了两个关键函数:

// lib/email.ts
// 完整代码地址:https://github.com/weijunext/nextjs-15-starter/blob/main/lib/email.ts

function validateEmail(email: string) {
  // 验证邮箱格式
  // 检查域名长度
  // 检查一次性邮箱
  // 检查特殊字符
}

function normalizeEmail(email: string) {
  // 标准化处理
  // 处理别名 (如 Gmail 的点号和加号后缀)
}

这两个方法创建了一些常见的验证,例如邮箱格式、防止一次性邮箱、处理邮箱别名等,如果你有更多有用的验证方法,可以很方便地进行扩展。

API 实现

在服务器端,我们需要处理订阅请求:

// app/api/newsletter/route.ts
// 完整代码地址:https://github.com/weijunext/nextjs-15-starter/blob/main/app/api/newsletter/route.ts

import { normalizeEmail, validateEmail } from '@/lib/email';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { Resend } from 'resend';

// 初始化 Resend
const resend = new Resend(process.env.RESEND_API_KEY);
// Resend Audience ID
const AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID!;

export async function POST(request: Request) {
  try {
    // 处理请求数据
    const { email } = await request.json();
    const normalizedEmail = normalizeEmail(email);

    // 验证邮箱……

    // 生成退订令牌和链接
    const unsubscribeToken = Buffer.from(normalizedEmail).toString('base64');
    const unsubscribeLink = `${process.env.NEXT_PUBLIC_SITE_URL}/unsubscribe?token=${unsubscribeToken}`;

    // 检查用户是否已存在
    const list = await resend.contacts.list({ audienceId: AUDIENCE_ID });
    if (list.data?.data.find((item) => item.email === normalizedEmail)) {
      return NextResponse.json({ success: true, alreadySubscribed: true });
    }

    // 将用户添加到 Resend Audience
    await resend.contacts.create({
      audienceId: AUDIENCE_ID,
      email: normalizedEmail,
      // 注释: 可添加更多用户信息
    });

    // 发送欢迎邮件
    await resend.emails.send({
      from: process.env.ADMIN_EMAIL!,
      to: email,
      subject: 'Welcome to Next Forge',
      html: `
        <h2>Welcome to Next Forge</h2>
        <p>Thank you for subscribing to the newsletter. You will receive the latest updates and news.</p>
        <p style="margin-top: 20px; font-size: 12px; color: #666;">
          If you wish to unsubscribe, please <a href="${unsubscribeLink}">click here</a>
        </p>
      `,
      headers: {
        "List-Unsubscribe": `<${unsubscribeLink}>`,
        "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
      }
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('邮箱订阅失败:', error);
    return NextResponse.json({ error: '服务器处理请求失败' }, { status: 500 });
  }
}

这个 API 可以接受邮箱,并把邮箱添加到 Resend 的 Audience 面板,这样可以很方便地管理订阅者,同时向订阅者发送一封订阅成功的提醒。

因为根据邮件营销最佳实践,每一封发给订阅者的邮件都要提供退订入口,所以邮箱里允许用户打开 unsubscribe 页面进行退订,我们通过 Buffer.from(normalizedEmail).toString('base64') 生成当前用户标识。

用户退订页面

用户退订会打开一个携带唯一标识的地址,我们可以写一个服务端组件来接收和处理:

// app/unsubscribe/page.tsx
// 完整代码地址:https://github.com/weijunext/nextjs-15-starter/blob/main/app/unsubscribe/page.tsx
export default async function UnsubscribePage({ searchParams }: { searchParams: { token?: string } }) {
  let status: "error" | "success" = "error";
  let email = "";
  let errorMessage = "处理您的退订请求时出现问题";

  const token = searchParams.token;

  if (!token) {
    errorMessage = "未提供退订令牌";
  } else {
    // 执行退订操作
    const result = await unsubscribe(token);
    
    if (result.success) {
      status = "success";
      email = result.email || "";
    } else {
      errorMessage = result.error || "处理您的退订请求时出现问题";
    }
  }

  return (
    <div>
      <h1>邮件订阅管理</h1>
      {status === "success" ? (
        <div>
          <p>您已成功退订「Next.js 中文文档」的邮件通知。</p>
          <p>邮箱: {email}</p>
        </div>
      ) : (
        <div>
          <p>{errorMessage}</p>
          <p>请确保您使用了正确的退订链接。</p>
        </div>
      )}
    </div>
  );
}

总结

以上即可完成一个 newsletter 功能,你可以到 nextforge.dev 体验。

文章开头说到,本文是接入基础,所以如果你的产品用户量比较大、业务逻辑复杂、安全要求高,你必须在基础功能以上,自主添加更多安全措施,例如利用 upstash redis 的 limiter 防止恶意重复提交、开启机器人识别、中间件对请求进行安全过滤等等多种措施。

关于我

🧑‍💻独立开发|⛵️出海|Next.js手艺人

🖥️做过开源:http://github.com/weijunext
⌨️写过博客:https://weijunext.com
🛠️今年想做独立产品和课程
📘Nextjs中文文档:http://nextjscn.org
📙全栈开发教程:https://ship.weijunext.com

欢迎在以下平台关注我:

如何为 Plasmo 开发的 Chrome 扩展添加 Google Analytics

2025-03-18 08:00:00

Web 添加 Google Analytics 是非常容易的,引入一个脚本就行,如果你还不会,可以直接用我的 Next.js 启动模板

但是,Chrome 插件要添加 Google Analytics 就不太方便了,因为从 manifest v3 开始,不允许使用外部 js 脚本了,也就是无法通过注入 Google Analytics 提供的脚本来实现。根据最新的规范,Chrome 插件想要添加 Google Analytics,需要由产品端主动发起 post 请求,向 Google Analytics 提交数据。

本文就来介绍一下如何在 Plasmo 开发的 Chrome 插件里添加 Google Analytics 4 (GA4)。

为什么在 Chrome 扩展中使用GA?

在添加 Google Analytics 之前,我都是在 Chrome 插件后台看安装数据,其中有一项是「一段时间内的每周用户数」,我一直以为展示的是日活数据,直到我点开这个小问号:

一段时间内的每周用户数

原来这里还包含了已停用的用户,那么和日活数据会有很大出入。为了能看到相对精准的数据,我决定引入 Google Analytics。

当然,除了想要更精准的数据外,从运营侧考虑还可能有这些原因:

  • 了解用户参与度和功能使用情况
  • 跟踪转化路径和用户旅程
  • 基于数据做出功能开发决策

接下来我们进入正题——介绍实现步骤。

创建 Google Analytics 媒体

登录 Google Analytics,在右下角 Admin - Create - Property 这里创建一个新的媒体

Property

创建完成后,进入当前媒体的页面,来到 Admin - Data collecting and modification - Data streams

Data streams

点击所创建的数据流,会看到这样的界面:

Data streams

复制 Measurement Id,并且在 Measurement Protocol API secrets 这里创建一个新的 api_secret

设置环境变量

把上面复制的 Measurement Id 和 创建的 Measurement Protocol API secrets 加入环境变量

PLASMO_PUBLIC_GTAG_ID=G-XXXXXXXXXX  # Measurement Id
PLASMO_PUBLIC_SECRET_API_KEY=XXXXXXXX  # api_secret

核心实现

核心代码只有一个文件,我们先创建一个文件 lib/googleAnalytics.ts,一步步加入代码

1. 基础设置

import { Storage } from "@plasmohq/storage"

if (!process.env.PLASMO_PUBLIC_GTAG_ID) {
  throw new Error("PLASMO_PUBLIC_GTAG_ID 环境变量未设置。")
}

if (!process.env.PLASMO_PUBLIC_SECRET_API_KEY) {
  throw new Error("PLASMO_PUBLIC_SECRET_API_KEY 环境变量未设置。")
}

const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect"
const G_TAG_ID = process.env.PLASMO_PUBLIC_GTAG_ID
const SECRET_API_KEY = process.env.PLASMO_PUBLIC_SECRET_API_KEY
const SESSION_EXPIRATION_IN_MSEC = 1000 * 60 * 30 // 30分钟
const storage = new Storage()

代码首先确保所需的环境变量存在。Plasmo 的 Storage 模块为客户端ID和会话管理提供持久化存储。

2. 客户端ID管理

Google Analytics 需要一个稳定的客户端ID来跟踪跨会话的用户:

async function getClientId(): Promise<string> {
  let clientId = await storage.get<string>("ga_client_id")

  if (!clientId) {
    // 生成UUID v4
    clientId = crypto.randomUUID()
    await storage.set("ga_client_id", clientId)
  }

  return clientId
}

此函数检索现有客户端ID或使用 crypto.randomUUID() 创建新ID,它生成UUID v4——非常适合匿名用户识别。

3. 会话管理

会话帮助分组用户交互:

async function getSessionId(): Promise<string> {
  let sessionId = await storage.get<string>("ga_session_id")
  const sessionTimestamp = await storage.get<number>("ga_session_timestamp") || 0

  // 在以下情况创建新会话:
  // 1. 没有会话存在
  // 2. 会话已超过 SESSION_EXPIRATION_IN_MSEC
  const now = Date.now()

  if (!sessionId || (now - sessionTimestamp > SESSION_EXPIRATION_IN_MSEC)) {
    sessionId = crypto.randomUUID()
    await storage.set("ga_session_id", sessionId)
    await storage.set("ga_session_timestamp", now)
  }

  return sessionId
}

这段代码创建或检索会话ID并管理会话超时。

因为 Chrome 插件和 Web 是不一样的,本身没有会话概念,所以我们自定义一个会话和超时时间,如果在超时时间内重复打开插件,就当作是一个会话。

4. 用户隐私和退出选项

async function isAnalyticsOptedOut(): Promise<boolean> {
  return await storage.get<boolean>("analytics_opted_out") || false
}

export async function optOutOfAnalytics(): Promise<void> {
  await storage.set("analytics_opted_out", true)
}

export async function optInToAnalytics(): Promise<void> {
  await storage.set("analytics_opted_out", false)
}

根据 Google 给的规范,如果有收集用户数据,最好提供一个允许用户关闭的入口,所以我们需要在这里添加以上 3 个方法来支持这个功能。

5. 发送事件

这一步是向 GA 发送事件的核心函数:

export async function sendEvent(event: CollectEventPayload): Promise<void> {
  // 如果用户选择退出则跳过
  if (await isAnalyticsOptedOut()) return

  try {
    const clientId = await getClientId()
    const sessionId = await getSessionId()

    const url = `${GA_ENDPOINT}?measurement_id=${G_TAG_ID}&api_secret=${SECRET_API_KEY}`

    const payload = {
      client_id: clientId,
      session_id: sessionId,  // 如果注释掉,每次打开都会被统计到
      events: [event],
      // 需要时包含用户属性
      user_properties: {
        extension_version: {
          value: chrome.runtime.getManifest().version
        }
      }
    }

    const response = await fetch(url, {
      method: "POST",
      body: JSON.stringify(payload),
      headers: {
        "Content-Type": "application/json"
      }
    })

    if (!response.ok) {
      console.error("GA4事件跟踪失败:", await response.text())
    }
  } catch (error) {
    console.error("发送分析事件时出错:", error)
  }
}

这个函数:

  1. 检查用户是否已选择退出
  2. 获取客户端ID和会话ID
  3. 使用事件数据构建 GA 载荷
  4. 将数据发送到 GA 测量协议端点
  5. 包含请求失败的错误处理

6. 定义常见事件

我们把常用的事件也定义在这个页面,例如:

export const Events = {
  PAGE_VIEW: (page_title: string) => ({
    name: "page_view",
    params: {
      page_title,
      page_location: document.location.href
    }
  }),

  BOOKMARK_ADDED: (source: string) => ({
    name: "bookmark_added",
    params: {
      source
    }
  }),

  SEARCH: (search_term: string, engine: string) => ({
    name: "search",
    params: {
      search_term,
      engine
    }
  }),

  // 更多事件...
}

7. 完整代码

// lib/googleAnalytics.ts
import { Storage } from "@plasmohq/storage"

if (!process.env.PLASMO_PUBLIC_GTAG_ID) {
  throw new Error("PLASMO_PUBLIC_GTAG_ID environment variable not set.")
}

if (!process.env.PLASMO_PUBLIC_SECRET_API_KEY) {
  throw new Error("PLASMO_PUBLIC_SECRET_API_KEY environment variable not set.")
}

const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect"
const G_TAG_ID = process.env.PLASMO_PUBLIC_GTAG_ID
const SECRET_API_KEY = process.env.PLASMO_PUBLIC_SECRET_API_KEY
const SESSION_EXPIRATION_IN_MSEC = 5 * 60 * 1000 // 5 minutes
// const DEFAULT_ENGAGEMENT_TIME_IN_MSEC = 100
const storage = new Storage()

// https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events
type CollectEventPayload = {
  name: string
  params?: Record<string, any>
}

// Generate or retrieve a unique client ID
async function getClientId(): Promise<string> {
  let clientId = await storage.get<string>("ga_client_id")

  if (!clientId) {
    // Generate a UUID v4
    clientId = crypto.randomUUID()
    await storage.set("ga_client_id", clientId)
  }

  return clientId
}

// Generate or retrieve session ID
async function getSessionId(): Promise<string> {
  let sessionId = await storage.get<string>("ga_session_id")
  const sessionTimestamp = await storage.get<number>("ga_session_timestamp") || 0

  // Create a new session if:
  // 1. No session exists
  // 2. Session is older than SESSION_EXPIRATION_IN_MSEC
  const now = Date.now()

  if (!sessionId || (now - sessionTimestamp > SESSION_EXPIRATION_IN_MSEC)) {
    sessionId = crypto.randomUUID()
    await storage.set("ga_session_id", sessionId)
    await storage.set("ga_session_timestamp", now)
  }

  return sessionId
}

// Check if user has opted out of analytics
async function isAnalyticsOptedOut(): Promise<boolean> {
  return await storage.get<boolean>("analytics_opted_out") || false
}

// Send event to Google Analytics 4
export async function sendEvent(event: CollectEventPayload): Promise<void> {
  // Skip if user opted out
  if (await isAnalyticsOptedOut()) return

  try {
    const clientId = await getClientId()
    const sessionId = await getSessionId()

    const url = `${GA_ENDPOINT}?measurement_id=${G_TAG_ID}&api_secret=${SECRET_API_KEY}`

    const payload = {
      client_id: clientId,
      // session_id: sessionId,
      events: [event],
      // Include user properties if needed
      user_properties: {
        extension_version: {
          value: chrome.runtime.getManifest().version
        }
      }
    }

    const response = await fetch(url, {
      method: "POST",
      body: JSON.stringify(payload),
      headers: {
        "Content-Type": "application/json"
      }
    })

    if (!response.ok) {
      console.error("GA4 event tracking failed:", await response.text())
    }
  } catch (error) {
    console.error("Error sending analytics event:", error)
  }
}

// Common events
export const Events = {
  PAGE_VIEW: (page_title: string, page_location: string) => ({
    name: "page_view",
    params: {
      page_title,
      // engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_IN_MSEC,
      page_location: document.location.href
    }
  }),

  BOOKMARK_ADDED: (source: string) => ({
    name: "bookmark_added",
    params: {
      source
    }
  }),

  SEARCH: (search_term: string, engine: string) => ({
    name: "search",
    params: {
      search_term,
      engine
    }
  }),

  TRENDING_FILTER: (language: string, time_range: string) => ({
    name: "trending_filter",
    params: {
      language,
      time_range
    }
  }),

  EXTERNAL_LINK_CLICK: (link_url: string, link_domain: string, link_type: string) => ({
    name: "external_link_click",
    params: {
      link_url,
      link_domain,
      link_type
    }
  }),

  THEME_CHANGE: (theme: string) => ({
    name: "theme_change",
    params: {
      theme
    }
  })
}

// Add functions for opt-in/out
export async function optOutOfAnalytics(): Promise<void> {
  await storage.set("analytics_opted_out", true)
}

export async function optInToAnalytics(): Promise<void> {
  await storage.set("analytics_opted_out", false)
}

在插件前端中的使用

以新标签页插件为例,需要在 tabs/index.tsx 文件里调用:

import { Events, sendEvent } from "~lib/googleAnalytics"

function NewTab() {
  useEffect(() => {
    // 当新标签页打开时跟踪页面浏览
    sendEvent(Events.PAGE_VIEW("新标签页"))
  }, [])

  // 组件实现...
}

如果你的插件是 Popup,用法也是一样。

清单权限

为了让 GA 工作,还需要在 manifest 添加权限:

"host_permissions": [
  "https://www.google-analytics.com/*"
],
"permissions": [
  "storage"
]

参考资源:

结论

本文介绍了在 Plasmo 开发的 Chrome 插件里如何添加 Google Analytics,并且实现了这些特性:

  • 匿名用户识别(客户端ID)
  • 会话管理
  • 事件跟踪
  • 用户隐私(退出选项)
  • 错误处理

本文的实现方式本来要在我的插件 nTab 中使用,但最终考虑到客户端还要发送请求,而且国内用户可能无法发送成功,所以暂时取消了。如果你们的插件主要面向海外用户,可以尝试使用起来。

nTab 是一个为程序员/开发者专门设计的新标签页插件,你可以在这里查找优质的开源项目、GitHub Trending、Hacker News 和其他多个平台的热门信息,还可以自定义常用标签,让工作更高效。未来会陆续增加新功能,欢迎开发者来围观和使用!

Next Idea NewTab

关于我

🧑‍💻独立开发|⛵️出海|Next.js手艺人

🖥️做过开源:http://github.com/weijunext
⌨️写过博客:https://weijunext.com
🛠️今年想做独立产品和课程
📘Nextjs中文文档:http://nextjscn.org
📙全栈开发教程:https://ship.weijunext.com

欢迎在以下平台关注我:

自部署Dokploy和Vercel、Zeabur项目迁移手册

2025-02-06 08:00:00

昨天突然发现一个部署在 Zeabur 的网站打不开了,这个网站最近的访问量持续上涨,无法访问意味着会失去潜在的新用户,而我在 Zeabur 每个月付费 10+ 刀。支付着高额账单却没有稳定的服务,于是我决定把部署在 Zeabur 的服务迁移走。

正好我最近计划购买服务器自部署一些开源服务,趁这个机会把 Dokploy 给部署起来,既可以替换掉 Zeabur,也可以把 Vercel 上面一部分占用资源大的项目迁移过来,还能作为以后自部署开源服务的稳定性试验,一举多得。

需要说明的是,我在 Zeabur 遇到的问题可能只是个例。事实上,Zeabur 是一个优秀的部署平台,我有其他项目在上面运行了很长时间都非常稳定。本文重点是分享自部署 Dokploy 的经验,而不是对任何平台的评价。

什么是 Dokploy

Dokploy 是一个专注于自托管的 PaaS (Platform as a Service) 解决方案,是 Vercel/Netlify/Zeabur 的开源替代品,区别是 Dokploy 专注于 Docker 容器部署。

dokploy

如果你熟悉 Docker,会很容易上手本文介绍的工作流,如果不熟悉,可能就会和我一样花半天时间才搞定一切。

自部署 Dokploy

购买和配置 VPS

要自部署 Dokploy,首先得购买服务器。对比几个云厂商的价格后,我选择了 hostinger 的 vps。

进入 hostinger 网站后,先把语言切换到中文再购买,因为我对比了几个地区的价格,发现人民币付款是最便宜的。

hostinger

选购2核8G的vps,两年只要1037元,换算下来,每个月不到6刀,还要啥自行车。

付款后会进入vps设置流程,根据页面提示操作就好了,全部完成后控制台会显示vps是启动状态。

接着添加防火墙规则,hostinger 默认没有防火墙规则,意味着所有端口都是开放的,这样风险比较高。我们需要创建防火墙规则,并开放 22、80、443、3000 端口访问。

hostinger

hostinger

安装 Dokploy

打开命令行,使用 ssh 方式登录vps,执行 Dokploy 安装命令:

curl -sSL https://dokploy.com/install.sh | sh

等待一会儿,安装成功后会有提示:

Dokploy

现在打开截图里的地址就可以访问 Dokploy 的管理后台。

注册登录后,进入如图的管理后台,先给管理后台设置自定义域名

Dokploy

再到域名解析平台(我的网站在 CloudFlare 解析,所以截图是 CloudFlare 的界面)添加这个自定义域名的解析记录,选择 A 类型解析,IP 地址填写服务器地址。

Dokploy

解析成功后,就可以使用自定义域名访问 Dokploy 管理后台了。

绑定 git

接着绑定你的 git 账号,这一步还是跟着页面提示操作,步骤和其他平台差不多,绑定完成后如图👇

Dokploy

验证部署流程

在 Vercel 上面,我们可以直接选择项目进行构建,不过在 Dokploy 需要先创建 Project,再创建 Service,然后才能部署项目

Dokploy

同一个 Project 下面的 Service 可以设置公共环境变量

Dokploy

创建 Service,并进入 Service 管理界面

Dokploy

Dokploy

在 Provider 依次选择账号、仓库、分支,然后点击 Save,再开始 Deploy

Dokploy

每个 Service 都有通用设置(General)、环境变量(Enviroment)、日志(Logs)、构建(Deployments)等等设置项,这些功能也跟 Vercel 等平台类似。

需要提醒的是,域名重定向的功能不在 Domain 里面,而是在 Advanced - Redirects。

Dokploy

通常我们选择「带 www 前缀的域名指向不带 www 的域名地址」,也就是下拉框的第二个选项

Dokploy

等 Service 构建完成了,我们到 Domains 里面添加域名,可以手动输入自定义域名,也可以点击右边的骰子生成域名

Dokploy

生成域名后,点击能打开页面就验证部署成功了。

如果是在 CloudFlare 解析域名,需要给 SSL 选择“完全”的策略

CloudFlare

搭配 GitHub Actions 使用

起因是,不少 Dokploy 尝鲜用户发现在服务器负载高的时候,会出现部署失败的情况,有开发者提出解决方案:搭配 GitHub Actions,部署前先通过 GitHub Actions 自动构建 Docker 镜像,Dokploy 只需要拉取构建好的镜像,不需要执行构建程序,这样就不会出现构建失败的问题。

创建 GitHub Token

要使用 GitHub Actions,需要先到 GitHub 设置里创建 Personal access token,点此直达,创建一个新的 Token

GitHub Token

创建完成后,会看到 token,要保存下来,等下要用。

如果忘了保存,可以重新回到这里,点击蓝色字进去重新生成(Regenerate token)

GitHub Token

Regenerate token

Dokploy 配置 Docker Register

回到 Dokploy 管理后台,进入 Registry 模块,添加 Registry

Register

Registry Name 随便填,Username 填你的 GitHub ID,Password 填上面生成的 token,Reigstry URL 填 https://ghcr.io

Register

Dokploy 修改部署方式

再打开 Service 的管理页面,进入 Advanced,我们要修改 Cluster Settings

Service

registry 选择刚才创建的那一个,然后 Save

Cluster Settings

接着点开 General,Provider 选择 Docker,Docker Image 输入 ghcr.io/[GitHub ID]/[Repo Name]:[Branch],然后 Save

Docker Image

修改成功后,会看到 Service 状态是置灰的。我们点开 Deployments,可以看到一个 Webhook URL,这个 URL 下一步要用。

Webhook URL

代码添加工作流和 Dockerfile 配置信息

在根目录创建文件 .github/workflow/docker-image.yml,这是一个 GitHub Actions 工作流配置文件,用来自动化构建和发布 Docker 镜像:

name: Create and publish a Docker image

on:
  push:
    branches: ['main']

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push-image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      attestations: write
      id-token: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Log in to the Container registry
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
      - name: Build and push Docker image
        id: push
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Trigger dokploy redeploy
        run: |
          curl -X GET https://xxxxxxxxxxxxxxxxxxxxx

注意看最后一行,https地址需要换成上一步看到的 Webhook URL。

现在还需要在根目录创建 Dockerfile 文件来定义如何构建这个镜像:

FROM node:20-alpine AS deps
WORKDIR /app

COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && corepack prepare [email protected] --activate && pnpm i --frozen-lockfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED 1

RUN corepack enable && corepack prepare [email protected] --activate && pnpm build

FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000
ENV HOSTNAME "0.0.0.0"

CMD ["node", "server.js"]

我使用的是 pnpm,所以这里配置了 pnpm 命令,并且要保证 pnpm 版本和 pnpm-lock.yaml 匹配,我直接设置为和本地一样的版本号,也就是 [email protected],你使用的时候最好换成自己的版本号,否则容易构建失败。

在 Docerkfile 配置里,我们使用了 standalone 输出模式,这个模式可以减少最终镜像的大小,并优化部署性能。对应的,我们还需要在 next.config.mjs 里面添加 output: standalone 这个配置:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone",
  // ... 其他配置
}

出于规范,还可以在根目录添加 .dockerignore 文件:

.git
.github
node_modules
.next

验证 Docker 部署流程

现在提交代码,打开 GitHub 仓库的 Actions 模块,会看到工作流正在执行,等待几分钟执行完成后,到 Dokploy 管理后台查看构建记录,会看到最新镜像已经拉取到了并且更新完成了

Deployments

到这里就算完成整个流程了,以后代码更新就会自动触发构建流程,自动更新。

按照这样的步骤,可以快速把 Vercel 等平台的项目迁移过来了,我已经把「Next.js 中文文档」迁移到自部署的 Dokploy,欢迎大家一起体验服务的稳定性🤣。

最后提醒一下,建议在 Web Server 模块打开每日清除 Docker 镜像的功能,这样系统会自动清除废弃的镜像,节省服务器空间。

clean docker

GitHub Actions 设置生产环境变量

这是很重要的一步,以上使用 GitHub Actions 部署的方式需要把 .env 文件上传到 GitHub 才行,但是大多数情况下我们是不会把 .env 文件上传网络。

这时候就需要使用 GitHub Actions 的环境变量设置功能了,打开 GitHub - settings - secrets and variables - Actions - Secrets/Variables;如果是需要加密的环境变量,要添加到 Secrets,GitHub 会进行加密处理,如果无需加密,添加到 Variables 即可。

在 GitHub 上添加环境变量后,还需要修改 .github/workflows/docker-image.ymlDockerfile

.github/workflows/docker-image.ymlBuild and push Docker image 步骤里添加 build-args

// docker-image.yml

      - name: Build and push Docker image
        id: push
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

          # 如果是添加到 Secrets,这里使用 secrets.xxx,添加到 Variables 的使用 vars.xxx
          build-args: |
            NEXT_PUBLIC_SITE_URL=${{ vars.NEXT_PUBLIC_SITE_URL }}
            NEXT_PUBLIC_SITE_NAME=${{ vars.NEXT_PUBLIC_SITE_NAME }}
            ## 其他更多环境变量

对应的,在 Dockerfile 的构建阶段接收这些环境变量:

## 其他内容不变

# Rebuild the source code only when needed
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# 声明构建参数
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_SITE_NAME

# 设置为环境变量,使应用能够访问
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_SITE_NAME=${NEXT_PUBLIC_SITE_NAME}

## 其他内容不变

这样就能让 GitHub Acitons 读取到环境变量了。

参考资源

以上内容参考了 Dokploy 官方文档:

还有 javahu 老哥的工作流:

关于我

🧑‍💻独立开发|⛵️出海|Next.js手艺人

🖥️做过开源:http://github.com/weijunext
⌨️写过博客:https://weijunext.com
🛠️今年想做独立产品和课程
📘Nextjs中文文档:http://nextjscn.org
📙全栈开发教程:https://ship.weijunext.com

欢迎在以下平台关注我:

如何优雅地处理第三方网站高清 Logo 显示?

2025-01-17 08:00:00

今天正式发布了我的第一个新标签页插件:nTab

Next Idea NewTab

这是一个为开发者、独立开发者量身打造的新标签页插件,主要功能分3个模块:

  • 精选的优质开源项目,分析项目潜在的商机,帮助开发者找到Idea
  • 实时更新的 GitHub Trending,不错过当下热门项目
  • 开发者工具箱,高效便捷

有了这个插件,每次打开浏览器都能查找新 idea,一下子缩短了信息获取路径。

插件左侧的优质开源项目来源于我的开源项目导航站:Next Idea

这篇博客的主要目的不是介绍插件,是要讲一个插件里出现的一个 bug。

bug描述

在插件的右侧,是一大批工具的展示,每个工具都需要显示 logo,问题恰恰出现在 logo 的展示上。

logo 最初的展示方案是,手动从第三方网站里找到高清 logo 地址,如果没有高清 logo,则使用 https://favicon.im 的服务获取logo。这个方案在我测试的时候没发现问题,上线后有用户反馈页面上出现 bug:

bug

我第一反应就知道和国内网络连接 favicon.im 的稳定性有关。

解决这个 bug 的思路也简单:

  • 找到高清的 Logo
  • 做好回退方案

表演一下AI编程

启动 Claude,发送 “我的产品里要展示不同网站的高清 logo,并且要做好回退方案,给我写一个 React 组件”。

Claude 就开发分析如何找到高清 logo:

  • 加载 https://${domain}/apple-touch-icon.png
  • 利用 Google、DuckDuckGo 等第三方服务抓取

并且提供了一份组件代码:

import React, { useState } from 'react';

const WebsiteLogo = ({ domain, size = 32, className = "" }) => {
  const [imgSrc, setImgSrc] = useState(`https://${domain}/apple-touch-icon.png`);
  const [fallbackIndex, setFallbackIndex] = useState(0);
  
  const fallbackSources = [
    `https://${domain}/apple-touch-icon.png`,
    `https://${domain}/apple-touch-icon-precomposed.png`,
    `https://www.google.com/s2/favicons?domain=${domain}&sz=64`,
    `https://icons.duckduckgo.com/ip3/${domain}.ico`,
    `https://${domain}/favicon.ico`,
  ];

  const handleError = () => {
    const nextIndex = fallbackIndex + 1;
    if (nextIndex < fallbackSources.length) {
      setFallbackIndex(nextIndex);
      setImgSrc(fallbackSources[nextIndex]);
    }
  };

  return (
    <img 
      src={imgSrc}
      alt={`${domain} logo`}
      width={size}
      height={size}
      onError={handleError}
      className={`inline-block ${className}`}
      style={{ objectFit: 'contain' }}
    />
  );
};

export default WebsiteLogo;

这个组件已经实现了自动处理错误和降级方案,并且可以自定义大小和样式,看起来已经可行了。

但是咱们可是程序员,哪能被 AI 糊弄,继续鞭打,要求在加载状态添加占位符、logo 展示要有过渡效果、提供兜底的回退方案,并且每个回退方案只给1秒加载时间,超出即当作失败,去找下一个回退方案。

这样一份非常健壮的第三方网站 logo 展示的组件就实现好了,代码我放在开源项目 nextjs-15-starter 了,仓库的演示网站也能体验效果。

核心处理方法在这里:

  useEffect(() => {
    let timeoutId

    if (isLoading) {
      timeoutId = setTimeout(() => {
        handleError()
      }, timeout)
    }

    return () => {
      if (timeoutId) {
        clearTimeout(timeoutId)
      }
    }
  }, [imgSrc, isLoading])

  const handleError = () => {
    const nextIndex = fallbackIndex + 1
    if (nextIndex < fallbackSources.length) {
      setFallbackIndex(nextIndex)
      setImgSrc(fallbackSources[nextIndex])
      setIsLoading(true)
    } else {
      setHasError(true)
      setIsLoading(false)
    }
  }

现在组件就完成了如下任务:

  • 多重备选图标源,确保最大程度显示成功
  • 加载状态显示占位符
  • 超时处理机制
  • 优雅的降级显示(使用域名首字母)
  • 可自定义大小和样式

轻松解决不同网站的favicon格式不一、图标无法加载、加载超时等等痛点。

最后,欢迎开发者们使用我的新标签页插件
插件官网👉 https://ntab.dev/
安装地址👉 https://chromewebstore.google.com/detail/next-idea-newtab/gneedaehihepidbpdenhmlmgpdaiaepo

关于我

🧑‍💻独立开发|⛵️出海|Next.js手艺人

🖥️做过开源:http://github.com/weijunext
⌨️写过博客:https://weijunext.com
🛠️今年想做独立产品和课程
📘Nextjs中文文档:http://nextjscn.org
📙全栈开发教程:https://ship.weijunext.com

欢迎在以下平台关注我:

5分钟搞定!给Next.js项目集成Algolia DocSearch搜索功能

2024-11-08 08:00:00

前言

半年前,我开源了一个 Next.js 博客模板。当时为了实现站内搜索,我选择了 FlexSearch 方案。FlexSearch 是一个高性能的全文搜索库,在英文环境下表现优异。然而在实际测试中,我发现中文搜索存在严重问题。要解决这个问题,就需要引入 nodejieba 这样的中文分词库。但 nodejieba 依赖 C++ 编译环境,而我的网站都是部署在 serverless 环境,无法直接支持,所以那时候我只在模板里放了一个简化版的搜索,使用体验并不好。

因为搜索功能使用频率太低了,所以我后来就没怎么研究了。直到和阿伟一起开发 Next.js 中文文档 的时候,阿伟给文档站集成了 Algolia DocSearch,我才重新准备修改博客模板的搜索功能。让我惊讶的是,集成 DocSearch 太方便了,我花了5分钟就上线新的搜索功能。

Algolia DocSearch 简介

Algolia 提供了付费的企业版和免费的 DocSearch 两种方案。DocSearch 专门针对技术文档、博客等内容网站,只要你的网站是公开可访问的,就能免费集成。

DocSearch 凭借专业的搜索能力和丝滑的用户体验,已经成为技术文档的主流搜索方案。连 React 和 Vue 这样的官方网站都在使用。

这篇教程将手把手带你在 Next.js 项目中集成 DocSearch,只需跟随以下步骤,你也能很快为网站添加专业级的站内搜索。

1、申请 DocSearch

  1. 填写表单申请:https://docsearch.algolia.com/apply/

  2. 等待 Algolia 团队的邮件,等待了一天,我就收到了这样的一封邮件:

    email.webp

收到邮件就表示 Algolia 已经索引好了我们的网站,现在就可以集成了。注意圈起来的参数,后面开发的搜索框组件需要使用这三个核心参数。

2、代码集成

安装依赖

# 使用 npm
npm install @docsearch/css @docsearch/react

# 使用 yarn
yarn add @docsearch/css @docsearch/react

# 使用 pnpm
pnpm add @docsearch/css @docsearch/react

创建配置文件 config/docSearchConfig.ts

interface DocSearchConfig {
  docSearch: {
    appId: string;
    indexName: string;
    apiKey: string;
  }
}

export const docSearchConfig: DocSearchSiteConfig = {
  docSearch: {
    appId: "填写邮件收到的参数",
    indexName: "填写邮件收到的参数",
    apiKey: "填写邮件收到的参数",
  },
}

创建 DocSearch 搜索框组件 components/DocSearch/index.ts,实现一个功能完备的搜索组件:

"use client";

import { docSearchConfig } from "@/config/docSearch";
import "@docsearch/css";
import { DocSearchModal, useDocSearchKeyboardEvents } from "@docsearch/react";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { IoIosSearch } from "react-icons/io";
import "./docSearch.css";

export default function CustomDocSearch() {
  const { appId, indexName, apiKey } = docSearchConfig.docSearch;
  const [isOpen, setIsOpen] = useState(false);
  const [isMac, setIsMac] = useState(false);
  const searchButtonRef = useRef<HTMLButtonElement>(null);

  const onOpen = useCallback(() => {
    setIsOpen(true);
  }, [setIsOpen]);

  const onClose = useCallback(() => {
    setIsOpen(false);
  }, [setIsOpen]);

  useDocSearchKeyboardEvents({
    isOpen,
    onOpen,
    onClose,
    searchButtonRef,
  });

  // 添加检测操作系统的效果
  useEffect(() => {
    setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0);
  }, []);

  return (
    <>
      <button className="docSearch-btn" data-variant="large" onClick={onOpen}>
        搜索文档<kbd>{isMac ? "⌘K" : "Ctrl+K"}</kbd>
      </button>
      <button className="docSearch-btn" data-variant="medium" onClick={onOpen}>
        搜索<kbd>{isMac ? "⌘K" : "Ctrl+K"}</kbd>
      </button>
      <button
        className="docSearch-btn mr-2 hover:bg-accent border border-gray-300"
        data-variant="small"
        onClick={onOpen}
      >
        <IoIosSearch />
      </button>
      {isOpen &&
        createPortal(
          <DocSearchModal
            initialScrollY={window.scrollY}
            appId={appId}
            apiKey={apiKey}
            indexName={indexName}
            onClose={onClose}
            placeholder="搜索文档"
            hitComponent={({ hit, children }) => (
              <Link href={hit.url}>{children}</Link>
            )}
          />,
          document.body
        )}
    </>
  );
}

docSearch.css 是自定义的搜索样式,这部分不重要,不贴代码了,你可以到文末的开源地址查看。

再把搜索框组件 CustomDocSearch 引入 Header 就可以使用了。

总结

DocSearch 的搜索和集成都非常方便,现在我的博客模板博客信息差周刊都已经集成好了,这三个项目都是开源的,你可以直接复制我的组件代码。

也可以到「Next.js中文文档」体验,文档站一共有 300 多个文档,搜索效率依然非常高,所以你完全可以放心使用。

关于我

我是一名全栈工程师,Next.js 开源手艺人,AI降临派。

今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。

欢迎在以下平台关注我:

「译」Next.js 15 正式版发布

2024-10-22 08:00:00

本文是 Next.js 官方发布的 15 版本博客翻译。

Next.js 15 正式发布并可用于生产环境。本次发布基于 RC1RC2 的更新。我们在保证稳定性的同时,添加了一些令人兴奋的更新。现在就试试 Next.js 15:

# 使用新的自动升级 CLI
npx @next/codemod@canary upgrade latest

# ...或手动升级
npm install next@latest react@rc react-dom@rc

我们也很高兴能在本周四 (10 月 24 日) 的 Next.js Conf 上分享更多即将推出的内容。

以下是 Next.js 15 的新特性:

  • @next/codemod CLI:轻松升级到最新的 Next.js 和 React 版本。
  • 异步请求 API (破坏性变更):朝着简化渲染和缓存模型迈出的渐进式一步。
  • 缓存语义 (破坏性变更): fetch 请求、GET 路由处理程序和客户端导航默认不再缓存。
  • React 19 支持: 支持 React 19、React Compiler (实验性) 并改进 hydration 错误。
  • Turbopack Dev (稳定版): 性能和稳定性改进。
  • 静态路由指示器: 在开发期间展示静态路由的新可视化指示器。
  • unstable_after API (实验性): 在响应完成流式传输后执行代码。
  • instrumentation.js API (稳定版): 用于服务器生命周期可观测性的新 API。
  • 增强表单 (next/form): 使用客户端导航增强 HTML 表单。
  • next.config: next.config.ts 的 TypeScript 支持。
  • 自托管改进: 对 Cache-Control 头部有更多控制。
  • Server Actions 安全: 无法猜测的端点和删除未使用的 actions。
  • 打包外部包 (稳定版): App 和 Pages Router 的新配置选项。
  • ESLint 9 支持: 增加了对 ESLint 9 的支持。
  • 开发和构建性能: 改进构建时间和更快的 Fast Refresh。

使用 @next/codemod CLI 平滑升级

我们在每个主要的 Next.js 版本中都包含了 codemods (自动代码转换),以帮助自动处理破坏性变更的升级。

为了使升级更加平滑,我们发布了一个增强的 codemod CLI:

npx @next/codemod@canary upgrade latest

该工具帮助你将代码库升级到最新的稳定版或预发布版本。CLI 将更新你的依赖,显示可用的 codemods,并指导你应用它们。

canary 标签使用最新版本的 codemod,而 latest 指定 Next.js 版本。即使你正在升级到最新的 Next.js 版本,我们也建议使用 canary 版本的 codemod,因为我们计划根据你的反馈继续对该工具进行改进。

了解更多关于 Next.js codemod CLI 的信息。

异步请求 API (破坏性变更)

在传统的服务器端渲染中,服务器会等待请求后再渲染任何内容。但是,并非所有组件都依赖于请求特定的数据,因此没有必要等待请求来渲染它们。理想情况下,服务器应该在请求到达之前尽可能多地准备。为了实现这一点,并为未来的优化奠定基础,我们需要知道何时等待请求。

因此,我们正在将依赖于请求特定数据的 API (如 headerscookiesparamssearchParams) 转换为异步

import { cookies } from 'next/headers';

export async function AdminPanel() {
  const cookieStore = await cookies();
  const token = cookieStore.get('token');
  
  // ...
}

这是一个破坏性变更,影响以下 API:

  • cookies
  • headers
  • draftMode
  • params (在 layout.jspage.jsroute.jsdefault.jsgenerateMetadatagenerateViewport 中)
  • searchParams (在 page.js 中)

为了更容易迁移,这些 API 可以暂时同步访问,但在开发和生产环境中会显示警告,直到下一个主要版本。我们提供了一个 codemod 来自动完成迁移:

npx @next/codemod@canary next-async-request-api .

对于 codemod 无法完全迁移的情况,请阅读 升级指南。我们还提供了一个 示例 来说明如何将 Next.js 应用程序迁移到新的 API。

缓存语义

Next.js App Router 推出时具有独特的缓存默认值。这些默认值旨在默认提供最佳性能选项,同时在需要时可以选择退出。

根据你的反馈,我们重新评估了我们的 缓存启发式方法 以及它们如何与部分预渲染 (PPR) 和使用 fetch 的第三方库进行交互。

在 Next.js 15 中,我们将 GET 路由处理程序和客户端路由器缓存的默认值从默认缓存更改为默认不缓存。如果你想保留以前的行为,你可以继续选择启用缓存。

我们将在未来几个月继续改进 Next.js 中的缓存,并很快分享更多详细信息。

GET 路由处理程序默认不再缓存

在 Next 14 中,使用 GET HTTP 方法的路由处理程序默认会被缓存,除非它们使用动态函数或动态配置选项。在 Next.js 15 中,GET 函数默认不会缓存

你仍然可以使用静态路由配置选项来选择启用缓存,比如 export dynamic = 'force-static'

特殊的路由处理程序如 sitemap.tsopengraph-image.tsxicon.tsx 和其他 元数据文件 默认仍然是静态的,除非它们使用动态函数或动态配置选项。

客户端路由器缓存默认不再缓存页面组件

在 Next.js 14.2.0 中,我们引入了一个实验性的 staleTimes 标志,允许自定义配置 路由器缓存

在 Next.js 15 中,这个标志仍然可用,但我们正在更改默认行为,将页面段的 staleTime 设为 0。这意味着当你在应用程序中导航时,客户端将始终反映作为导航一部分而变为活动状态的页面组件的最新数据。然而,仍有一些重要的行为保持不变:

  • 共享布局数据不会从服务器重新获取,以继续支持 部分渲染
  • 后退/前进导航仍将从缓存恢复,以确保浏览器可以恢复滚动位置
  • loading.js 将保持缓存 5 分钟(或 staleTimes.static 配置的值)

你可以通过设置以下配置来选择使用之前的客户端路由器缓存行为:

// next.config.ts
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30
    }
  }
};

export default nextConfig;

React 19

作为 Next.js 15 发布的一部分,我们决定与即将发布的 React 19 保持一致。

在版本 15 中,App Router 使用 React 19 RC,根据社区反馈,我们还为使用 Pages Router 的 React 18 引入了向后兼容性。如果你使用 Pages Router,这允许你在准备好时升级到 React 19。

虽然 React 19 仍处于 RC 阶段,但我们在真实世界应用程序中的广泛测试以及与 React 团队的密切合作让我们对其稳定性充满信心。核心破坏性变更已经经过充分测试,不会影响现有的 App Router 用户。因此,我们决定现在将 Next.js 15 发布为稳定版,以便你的项目为 React 19 GA 做好充分准备。

为确保过渡尽可能顺利,我们提供了 codemods 和自动化工具 来帮助简化迁移过程。

阅读 Next.js 15 升级指南React 19 升级指南 并观看 React Conf 主题演讲 以了解更多信息。

Pages Router 上的 React 18

Next.js 15 保持了 Pages Router 与 React 18 的向后兼容性,允许用户在享受 Next.js 15 改进的同时继续使用 React 18。

从第一个发布候选版本(RC1)开始,根据社区反馈,我们将重点转向包含对 React 18 的支持。这种灵活性使你能够在使用 Pages Router 和 React 18 的同时采用 Next.js 15,让你对升级路径有更大的控制。

注意: 虽然在同一个应用程序中可以在 React 18 上运行 Pages Router、在 React 19 上运行 App Router,但我们不推荐这种设置。这样做可能会导致不可预测的行为和类型不一致,因为两个版本之间的底层 API 和渲染逻辑可能不完全一致。

React Compiler (实验性)

React Compiler 是由 Meta 的 React 团队创建的一个新的实验性编译器。该编译器通过理解纯 JavaScript 语义和 React 规则 来深入理解你的代码,这使得它能够为你的代码添加自动优化。编译器减少了开发人员通过 useMemouseCallback 等 API 进行手动记忆化的工作量 - 使代码更简单、更易维护且不易出错。

在 Next.js 15 中,我们添加了对 React Compiler 的支持。了解更多关于 React Compiler 和 可用的 Next.js 配置选项

注意: React Compiler 目前仅作为 Babel 插件提供,这将导致开发和构建时间变慢。

Hydration 错误改进

Next.js 14.1 改进了 错误消息和 hydration 错误。Next.js 15 继续在此基础上添加了改进的 hydration 错误视图。Hydration 错误现在会显示错误的源代码以及如何解决问题的建议。

例如,这是 Next.js 14.1 中的 hydration 错误消息:

Next.js 14.1 中的 Hydration 错误消息Next.js 14.1 中的 Hydration 错误消息

Next.js 15 改进后的效果:

Next.js 15 中改进的 Hydration 错误消息Next.js 15 中改进的 Hydration 错误消息

Turbopack Dev

我们很高兴地宣布 next dev --turbo 现在已经稳定并准备好加速你的开发体验。我们一直在使用它来迭代开发 vercel.comnextjs.orgv0 和我们所有其他的应用程序,并取得了很好的效果。

例如,对于 vercel.com 这样的大型 Next.js 应用:

  • 本地服务器启动速度快 76.7%
  • Fast Refresh 代码更新速度快 96.3%
  • 无缓存时的初始路由编译速度快 45.8% (Turbopack 目前还没有磁盘缓存)

你可以在我们的新 博文 中了解更多关于 Turbopack Dev 的信息。

静态路由指示器

Next.js 现在在开发过程中显示静态路由指示器,帮助你识别哪些路由是静态的或动态的。这个可视化提示让你更容易理解页面的渲染方式,从而优化性能。

你还可以使用 next build 输出来查看所有路由的渲染策略。

这个更新是我们持续努力增强 Next.js 可观测性的一部分,使开发人员更容易监控、调试和优化他们的应用程序。我们也在开发专门的开发者工具,更多细节即将公布。

了解更多关于 静态路由指示器 的信息,该功能可以被禁用。

使用 unstable_after 在响应后执行代码 (实验性)

在处理用户请求时,服务器通常执行与计算响应直接相关的任务。但是,你可能需要执行一些任务,如日志记录、分析和其他外部系统同步。

由于这些任务与响应没有直接关系,用户不应该等待它们完成。但是在响应结束后延迟执行工作会带来挑战,因为无服务器函数会在响应关闭后立即停止计算。

after() 是一个新的实验性 API,通过允许你安排在响应流式传输完成后处理的工作来解决这个问题,使次要任务能够在不阻塞主要响应的情况下运行。

要使用它,请在 next.config.js 中添加 experimental.after:

// next.config.ts
const nextConfig = {
  experimental: {
    after: true
  }
};

export default nextConfig;

然后在服务器组件、服务器 Actions、路由处理程序或中间件中导入该函数:

import { unstable_after as after } from 'next/server';
import { log } from '@/app/utils';

export default function Layout({ children }) {
  // 次要任务
  after(() => {
    log();
  });

  // 主要任务
  return <>{children}</>;
}

了解更多关于 unstable_after 的信息。

instrumentation.js (稳定版)

带有 register() API 的 instrumentation 文件允许用户接入 Next.js 服务器生命周期,以监控性能、追踪错误源头,并深度集成如 OpenTelemetry 等可观测性库。

这个功能现在已经稳定,可以移除 experimental.instrumentationHook 配置选项。

此外,我们与 Sentry 合作设计了一个新的 onRequestError 钩子,可用于:

  • 捕获服务器上抛出的所有错误的重要上下文,包括:
    • 路由器: Pages Router 或 App Router
    • 服务器上下文: 服务器组件、服务器 Action、路由处理程序或中间件
  • 将错误报告给你喜欢的可观测性提供商
export async function onRequestError(err, request, context) {
  await fetch('https://...', {
    method: 'POST',
    body: JSON.stringify({ message: err.message, request, context }),
    headers: { 'Content-Type': 'application/json' },
  });
}

export async function register() {
  // 初始化你喜欢的可观测性提供商 SDK
}

了解更多关于 onRequestError 函数 的信息。

<Form> 组件

新的 <Form> 组件扩展了 HTML <form> 元素,增加了 预获取客户端导航 和渐进增强功能。

它对于那些需要导航到新页面的表单很有用,比如导向结果页面的搜索表单。

// app/page.jsx
import Form from 'next/form';

export default function Page() {
  return (
    <Form action="/search">
      <input name="query" />
      <button type="submit">提交</button>
    </Form>
  );
}

<Form> 组件具有以下特性:

  • 预获取: 当表单出现在视图中时,布局loading UI 会被预获取,使导航变得快速。
  • 客户端导航: 在提交时,共享布局和客户端状态会被保留。
  • 渐进增强: 如果 JavaScript 还没有加载,表单仍然可以通过完整页面导航工作。

之前,要实现这些功能需要大量的手动模板代码。例如:

// 注意: 这是为了演示目的而简化的代码。
// 不建议在生产代码中使用。

'use client'

import { useEffect } from 'react'
import { useRouter } from 'next/navigation'

export default function Form(props) {
  const action = props.action
  const router = useRouter()

  useEffect(() => {
    // 如果表单目标是 URL,则预获取它
    if (typeof action === 'string') {
      router.prefetch(action)
    }
  }, [action, router])

  function onSubmit(event) {
    event.preventDefault()

    // 获取所有表单字段并触发带有数据 URL 编码的 `router.push`
    const formData = new FormData(event.currentTarget)
    const data = new URLSearchParams()

    for (const [name, value] of formData) {
      data.append(name, value as string)
    }

    router.push(`${action}?${data.toString()}`)
  }

  if (typeof action === 'string') {
    return <form onSubmit={onSubmit} {...props} />
  }

  return <form {...props} />
}

了解更多关于 <Form> 组件 的信息。

支持 next.config.ts

Next.js 现在支持 TypeScript 的 next.config.ts 文件类型,并提供了 NextConfig 类型用于自动补全和类型安全的选项:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  /* 在此处配置选项 */
};

export default nextConfig;

了解更多关于 Next.js 中的 TypeScript 支持

自托管改进

在自托管应用程序时,你可能需要对 Cache-Control 指令有更多控制。

一个常见的情况是控制 ISR 页面发送的 stale-while-revalidate 周期。我们实现了两项改进:

  1. 你现在可以在 next.config 中配置 expireTime 值。这之前是 experimental.swrDelta 选项。
  2. 将默认值更新为一年,确保大多数 CDN 能够按预期完全应用 stale-while-revalidate 语义。

我们也不再用默认值覆盖自定义的 Cache-Control 值,允许完全控制并确保与任何 CDN 设置兼容。

最后,我们改进了自托管时的图像优化。之前,我们建议你在 Next.js 服务器上安装 sharp 来优化图像。这个建议有时会被忽略。从 Next.js 15 开始,你不再需要手动安装 sharp - 当使用 next start 或在 standalone 输出模式 下运行时,Next.js 将自动使用 sharp

要了解更多信息,请查看我们关于自托管 Next.js 的新 演示和教程视频

Server Actions 的增强安全性

Server Actions 是可以从客户端调用的服务器端函数。它们通过在文件顶部添加 'use server' 指令和导出异步函数来定义。

即使 Server Action 或工具函数没有在代码的其他地方导入,它仍然是一个可公开访问的 HTTP 端点。虽然这种行为在技术上是正确的,但可能导致这些函数被无意中暴露。

为了提高安全性,我们引入了以下增强功能:

  • 死代码消除: 未使用的 Server Actions 的 ID 不会暴露给客户端 JavaScript 包,从而减少包的大小并提升性能。
  • 安全的 action ID: Next.js 现在创建无法猜测的、非确定性的 ID,以允许客户端引用和调用 Server Action。这些 ID 会在构建之间定期重新计算以增强安全性。
// app/actions.js
'use server';

// 这个 action **被**用在我们的应用程序中,所以 Next.js
// 将创建一个安全的 ID 来允许客户端引用
// 和调用这个 Server Action。
export async function updateUserAction(formData) {}

// 这个 action **没有**在我们的应用程序中使用,所以 Next.js
// 将在 `next build` 期间自动移除这段代码
// 并且不会创建公共端点。
export async function deleteUserAction(formData) {}

你仍然应该将 Server Actions 视为公共 HTTP 端点。了解更多关于 保护 Server Actions 的安全

优化外部包的打包 (稳定版)

打包外部包可以提高应用程序的冷启动性能。在 App Router 中,外部包默认会被打包,你可以使用新的 serverExternalPackages 配置选项来选择退出特定包。

Pages Router 中,外部包默认不会被打包,但你可以使用现有的 transpilePackages 选项提供要打包的包列表。使用此配置选项时,你需要指定每个包。

为了统一 App 和 Pages Router 之间的配置,我们引入了一个新选项 bundlePagesRouterDependencies,以匹配 App Router 的默认自动打包。如果需要,你可以使用 serverExternalPackages 来选择退出特定包。

// next.config.ts
const nextConfig = {
  // 在 Pages Router 中自动打包外部包:
  bundlePagesRouterDependencies: true,
  // 为 App 和 Pages Router 选择退出特定包的打包:
  serverExternalPackages: ['package-name'],
};

export default nextConfig;

了解更多关于 优化外部包 的信息。

ESLint 9 支持

Next.js 15 还引入了对 ESLint 9 的支持,这是在 ESLint 8 于 2024 年 10 月 5 日结束生命周期后推出的。

为确保平稳过渡,Next.js 保持向后兼容,这意味着你可以继续使用 ESLint 8 或 9。

如果你升级到 ESLint 9,并且我们检测到你还没有采用 新的配置格式,Next.js 将自动应用 ESLINT_USE_FLAT_CONFIG=false 转义措施以便于迁移。

此外,在运行 next lint 时将移除已弃用的选项,如 --ext--ignore-path。请注意,ESLint 最终将在 ESLint 10 中禁用这些旧配置,所以我们建议你尽快开始迁移。

有关这些更改的更多详细信息,请查看 迁移指南

作为此更新的一部分,我们还将 eslint-plugin-react-hooks 升级到 v5.0.0,它为 React Hooks 使用引入了新规则。你可以在 [email protected] 的更新日志 中查看所有更改。

开发和构建改进

服务器组件 HMR

在开发过程中,保存时会重新执行服务器组件。这意味着,对 API 端点或第三方服务的任何 fetch 请求也会被调用。

为了提高本地开发性能并减少可能会产生费用的 API 调用,我们现在确保热模块替换(HMR)可以重用之前渲染的 fetch 响应。

了解更多关于 服务器组件 HMR 缓存 的信息。

App Router 更快的静态生成

我们优化了静态生成以改善构建时间,特别是对于具有慢速网络请求的页面。

此前,我们的静态优化过程会渲染页面两次 - 一次用于生成客户端导航的数据,第二次用于渲染初始页面访问的 HTML。现在,我们重用第一次渲染,减少了第二次渲染,从而减少工作量和构建时间。

此外,静态生成工作进程现在在页面之间共享 fetch 缓存。如果一个 fetch 调用没有选择退出缓存,其结果会被同一工作进程处理的其他页面重用。这减少了对相同数据的请求数量。

高级静态生成控制 (实验性)

我们为那些需要更大控制权的高级用例添加了实验性支持,以便更好地控制静态生成过程。

我们建议坚持使用当前的默认值,除非你有特定的需求,因为这些选项可能会由于增加的并发性而导致资源使用增加和潜在的内存不足错误。

// next.config.ts
const nextConfig = {
  experimental: {
    // Next.js 在失败构建之前重试失败页面生成的次数
    staticGenerationRetryCount: 1,
    // 每个工作进程将处理的页面数量
    staticGenerationMaxConcurrency: 8,
    // 启动新的导出工作进程前的最小页面数
    staticGenerationMinPagesPerWorker: 25
  },
}

export default nextConfig;

了解更多关于 静态生成选项 的信息。

其他变更

  • [破坏性变更] next/image: 移除 squoosh 转而支持 sharp 作为可选依赖 (PR)
  • [破坏性变更] next/image: 将默认 Content-Disposition 更改为 attachment (PR)
  • [破坏性变更] next/image: 当 src 有前导或尾随空格时报错 (PR)
  • [破坏性变更] 中间件: 应用 react-server 条件以限制不推荐的 React API 导入 (PR)
  • [破坏性变更] next/font: 移除对外部 @next/font 包的支持 (PR)
  • [破坏性变更] next/font: 移除 font-family 哈希 (PR)
  • [破坏性变更] 缓存: force-dynamic 现在会将 no-store 设为 fetch 缓存的默认值 (PR)
  • [破坏性变更] 配置: 默认启用 swcMinify (PR)、missingSuspenseWithCSRBailout (PR) 和 outputFileTracing (PR),并移除已弃用选项
  • [破坏性变更] 移除 Speed Insights 的自动检测(现在必须使用专门的 @vercel/speed-insights 包) (PR)
  • [破坏性变更] 移除动态站点地图路由的 .xml 扩展名,并统一开发和生产环境之间的站点地图 URL (PR)
  • [破坏性变更] 我们已弃用在 App Router 中导出 export const runtime = "experimental-edge"。用户现在应该切换到 export const runtime = "edge"。我们添加了一个 codemod 来执行此操作 (PR)
  • [破坏性变更] 在渲染期间调用 revalidateTagrevalidatePath 现在将抛出错误 (PR)
  • [破坏性变更] instrumentation.jsmiddleware.js 文件现在将使用打包的 React 包 (PR)
  • [破坏性变更] 最低需要的 Node.js 版本已更新为 18.18.0 (PR)
  • [破坏性变更] next/dynamic: 已移除已弃用的 suspense prop,并且当组件在 App Router 中使用时,它不会再插入空的 Suspense 边界 (PR)
  • [破坏性变更] 在 Edge Runtime 上解析模块时,不会应用 worker 模块条件 (PR)
  • [破坏性变更] 禁止在服务器组件中使用带有 ssr: false 选项的 next/dynamic (PR)
  • [改进] 元数据: 更新了在 Vercel 上托管时 metadataBase 的环境变量回退 (PR)
  • [改进] 修复了使用 optimizePackageImports 的混合命名空间和命名导入的树摇 (PR)
  • [改进] 并行路由: 为未匹配的 catch-all 路由提供所有已知参数 (PR)
  • [改进] 配置 bundlePagesExternals 现在稳定并重命名为 bundlePagesRouterDependencies
  • [改进] 配置 serverComponentsExternalPackages 现在稳定并重命名为 serverExternalPackages
  • [改进] create-next-app: 新项目默认忽略所有 .env 文件 (PR)
  • [改进] outputFileTracingRootoutputFileTracingIncludesoutputFileTracingExcludes 已从实验性升级为稳定版 (PR)
  • [改进] 避免将全局 CSS 文件与树中更深层的 CSS 模块文件合并 (PR)
  • [改进] 现在可以通过 NEXT_CACHE_HANDLER_PATH 环境变量指定缓存处理程序 (PR)
  • [改进] Pages Router 现在同时支持 React 18 和 React 19 (PR)
  • [改进] 如果启用了检查器,错误覆盖现在会显示一个复制 Node.js Inspector URL 的按钮 (PR)
  • [改进] App Router 上的客户端预取现在使用 priority 属性 (PR)
  • [改进] Next.js 现在提供一个 unstable_rethrow 函数来在 App Router 中重新抛出 Next.js 内部错误 (PR)
  • [改进] unstable_after 现在可以在静态页面中使用 (PR)
  • [改进] 如果在 SSR 期间使用了 next/dynamic 组件,将会预取该块 (PR)
  • [改进] App Router 现在支持 esmExternals 选项 (PR)
  • [改进] experimental.allowDevelopmentBuild 选项可用于允许调试目的下使用 NODE_ENV=development 运行 next build (PR)
  • [改进] 在 Pages Router 中禁用了 Server Action 转换 (PR)
  • [改进] 构建工作进程现在会在退出时停止构建挂起 (PR)
  • [改进] 从 Server Action 重定向时,重新验证现在将正确应用 (PR)
  • [改进] 现在在 Edge Runtime 上正确处理并行路由的动态参数 (PR)
  • [改进] 静态页面现在在初始加载后会遵循 staleTime (PR)
  • [改进] vercel/og 更新了内存泄漏修复 (PR)
  • [改进] 更新了补丁时间以允许使用像 msw 这样的包进行 API 模拟 (PR)
  • [改进] 预渲染页面应使用静态 staleTime (PR)

要了解更多信息,请查看 升级指南

关于我

我是一名全栈工程师,Next.js 开源手艺人,AI降临派。

今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。

欢迎在以下平台关注我: