上海软件外包公司-泰乐坊科技 http://www.cheeli.com.cn 企业软件开发、手机移动、小程序、 ERP WMS 电商O2O Sat, 04 Apr 2026 20:20:30 +0000 zh-CN hourly 1 https://wordpress.org/?v=4.9.15 如何使用 Python、Django 以及 PythonAnywhere 来构建并部署一个健身追踪工具——一份适合初学者的指南 http://www.cheeli.com.cn/articles/how-to-build-and-deploy-a-fitness-tracker-using-python-django-and-pythonanywhere-a-beginner-friendly-guide/ Sat, 04 Apr 2026 20:20:30 +0000 http://www.cheeli.com.cn/?p=21111 Read More]]> 如果你已经学习了一些Python基础知识,但在尝试构建实际项目时仍然感到无从下手,那么你并不孤单。许多初学者都会先学习教程,了解变量、函数和循环等概念,但当真正尝试创建一个项目时就会遇到障碍。

从“我知道Python语法”到“能够开发出一个可运行的Web应用”,这个差距可能会让人觉得非常大。但实际上,这种差距并非不可避免。

在这个教程中,你将使用Django这一非常流行的Python Web框架,从零开始构建一个健身追踪Web应用。完成学习后,你将会拥有一个可以在互联网上正常运行的完整应用程序——你可以把它展示给朋友看,添加到自己的作品集里,或者继续在此基础上进行开发。

你将学到以下内容:

  • Django项目及其应用的架构结构

  • 如何定义数据库模型来存储锻炼数据

  • 如何创建处理用户请求的视图函数

  • 如何编写用于显示数据的HTML模板

  • 如何将URL与视图函数关联起来,以便用户能够浏览你的应用

  • 如何将完成的应用程序部署到PythonAnywhere上,让任何人都能访问它

这个应用程序本身非常简单:你可以通过输入运动名称、持续时间以及日期来记录一次锻炼情况,然后可以在另一个页面上查看所有记录下来的锻炼信息。虽然很简单,但它涵盖了构建更复杂应用所需的核心Django概念。

让我们开始吧。

目录

先决条件

在开始之前,请确保您已经掌握了以下内容:

Python基础知识:您需要了解变量、函数、列表、字典以及基本的控制结构(如if/else语句和循环)。

基本命令行使用方法:在本教程中,您需要在终端中执行各种命令。您应该知道如何打开终端、在文件夹间切换以及运行命令。如果在Windows系统中,可以使用命令提示符或PowerShell;在macOS或Linux系统中,默认的Terminal应用程序也能满足需求。

需要安装的工具:

  • Python 3.8或更高版本。您可以通过在终端中运行python --versionpython3 --version来检查自己的Python版本。如果还没有安装Python,可以从python.org下载。

  • pip。这是Python的包管理工具,通常会随Python一起安装。您可以通过运行pip --versionpip3 --version来验证它的存在。注意,命令python3pip3表示您正在使用Python 3版本

  • 代码编辑器。Visual Studio Code是一个非常优秀的免费选择,但您也可以使用任何自己熟悉的编辑器。

以上就是全部准备内容。您不需要具备Django的使用经验或网页开发知识,本教程会一步步引导您完成学习过程。

您将构建什么

您将要开发的这个健身追踪工具具有以下两个主要功能:

  1. 用于记录锻炼信息的表单。您可以在其中输入运动名称(例如“跑步”或“俯卧撑”)、运动时长(以分钟为单位)以及日期。提交表单后,Django会将这些信息保存到数据库中。

该图片展示了一个用于记录锻炼信息的表单

  1. 用于查看所有锻炼记录的页面。该页面会以清晰明了的方式列出您所记录的所有锻炼信息,包括运动类型、时长和日期。

该图片展示了一个列出了所有锻炼记录的页面

从高层次来看,数据在应用程序中的流动过程如下:

  1. 您在浏览器中填写锻炼记录表单并点击提交。

  2. 您的浏览器会将这些数据发送给Django。

  3. Django的视图函数会接收这些数据,对其进行验证,然后将其保存到数据库中。

  4. 当您访问锻炼记录页面时,Django的视图函数会从数据库中检索所有已保存的锻炼记录。

  5. Django会将这些数据传递给HTML模板,模板会将它们渲染成浏览器可以显示的页面。

该图片展示了这款健康追踪应用程序的数据流处理流程,共包含5个步骤

这种请求-响应机制是Django框架运作的基础。一旦你理解了这一原理,那么你几乎可以构建任何类型的应用程序。

步骤1:如何设置你的Django项目

每个Django项目在开始时都需要进行一些初始化设置。你需要创建一个独立的Python环境,安装Django框架,并生成项目的基本结构文件。

1.1 如何创建虚拟环境

虚拟环境是一个独立的文件夹,其中包含了专门为某个项目准备的Python解释器及已安装的软件包。这样的设置能够将你的项目所依赖的组件与电脑上其他Python项目分开来,从而避免版本冲突,并确保各种配置的一致性。

例如,有的项目可能需要使用较旧版本的Django框架,而另一个项目则必须使用最新版本;虚拟环境的存在使得这两个项目能够在同一系统中顺利运行。

如果没有虚拟环境,全局安装的软件包之间可能会发生冲突,从而导致项目无法正常运行,同时也会使配置过程变得复杂且难以重复。随着时间的推移,系统环境会逐渐充斥着那些未被使用或不兼容的软件包,进而增加调试和维护的难度。

现在,我们就来开始设置虚拟环境吧。

打开终端,导航到你想要存放项目的文件夹位置,然后执行以下命令:

mkdir fitness-tracker
cd fitness-tracker

终端界面中显示了“mkdir”和“cd”命令的输入过程

第一个命令用于创建一个名为“fitness-tracker”的新文件夹,第二个命令则会将当前目录切换到这个新文件夹中。

你将在这里创建Python虚拟环境。

python3 -m venv venv

该图片展示了创建Python虚拟环境的命令

上述命令会在名为“venv”的文件夹中创建一个虚拟环境。“python3 -m venv venv”这个命令中,第一个“venv”是命令本身,第二个“venv”则是该虚拟环境的名称。其实你可以给这个文件夹起任何名字,不过通常人们还是习惯使用“venv”这个名称。

通过执行“ls”命令,你可以确认虚拟环境文件夹确实已经创建成功了。

要激活这个虚拟环境,需要执行以下命令:

在macOS/Linux系统上:

source venv/bin/activate

在 Windows 上:

venv\Scripts\activate

当你在终端提示符的前面看到 (venv) 时,就说明激活成功了。从这时起,你安装的任何 Python 包都只会存在于这个 虚拟环境中

该图片展示了虚拟环境被激活的过程

1.2 如何安装 Django

在虚拟环境激活后,使用 pip 来安装 Django:

pip install django

这样就会下载并安装最新版本的 Django。你可以通过运行以下命令来验证安装是否成功:

python3 -m django --version

执行完这两条命令后,你应该会看到 Django 已经被安装完毕,同时也会显示其版本号:

该图片展示了 Django 的安装过程及已安装的版本信息

1.3 如何创建项目

Django 的安装已经完成,现在我们来创建一个 Django 项目。Django 提供了一个命令行工具,可以生成你需要的模板文件。请输入以下命令:

django-admin startproject fitness_project .

这条命令会创建一个名为 fitness-project 的文件夹。注意命令末尾的那个点,这个点非常重要——它告诉 Django 在当前目录中生成项目文件,而不会创建额外的嵌套文件夹。

现在我们的 Django 项目已经创建好了,接下来让我们在喜欢的文本编辑器中打开这个项目,看看它的文件夹结构。

你会发现这个文件夹里已经包含了很多文件了。

该图片展示了通过 django-admin startproject 命令创建的文件列表

1.4 如何运行开发服务器

现在让我们确认一下所有设置是否都正常工作。为此,你需要运行一个服务器。请输入以下命令:

python manage.py runserver

你可以在激活了虚拟环境的终端中输入这条命令,或者如果你使用的是 VS Code,也可以使用其内置的终端。从现在开始,我将会使用内置终端来操作。

该图片展示了在输入 runserver 命令后服务器启动的状态

打开您的浏览器,访问http://127.0.0.1:8000/。您应该会看到Django的默认欢迎页面,页面上还会显示一枚火箭图案,这证明您的项目已经设置正确。

这是Django默认主页的图片

当您准备继续下一步操作时,请在终端中按下Ctrl + C来停止服务器。

步骤2:如何创建Django应用程序

在Django中,项目是整个Web应用程序的总体框架,而应用程序则是该项目中的一个小模块,它专注于实现特定的功能。

用一个房子来打比方会更容易理解这一点:整个房子就是项目,而房子里的每个房间则对应着应用程序。其中一个房间可能是厨房,另一个可能是卧室,每个房间的设计都有其明确的用途。同样地,Django应用程序也是为完成某一项特定任务而开发的,比如身份验证、支付处理,或者像在这个例子中那样,用于记录锻炼情况。

现在来看看关键点:为什么不把所有功能都放在一个项目中呢?技术上来说,这是可行的,尤其是对于非常小的项目而言。但随着应用程序规模的扩大,这种做法很快就会变得难以管理。

通过使用应用程序,我们可以自然地将不同的功能区分开来。这样也能让协作变得更加顺畅,因为不同的人可以分别负责不同的应用程序,而不会经常干扰到彼此的代码。

另一个重要的好处是代码的可重用性。由于应用程序都是模块化的,因此我们可以将某个应用程序从一个项目中提取出来,然后重新用在另一个项目中。

例如,如果您曾经开发过一个用于记录锻炼情况的应用程序,之后就可以将其直接应用到另一个完全不同的Django项目中,而无需从头开始重新编写代码。后来,如果您又创建了一个新的项目,比如一个健身辅导平台或健康监测工具,那么您仍然可以重复使用之前那个记录锻炼功能的应用程序。

对于这个项目来说,您将创建一个名为tracker的应用程序,它将负责处理与记录和显示锻炼数据相关的所有功能。

2.1 如何生成应用程序

请确保您当前所处的目录与manage.py文件位于同一位置,然后运行以下命令:

python manage.py startapp tracker

这条命令会创建一个名为“tracker”的新文件夹,该文件夹的结构如下所示:

这张图片展示了执行startapp命令后生成的文件夹结构

这些文件各自有着不同的用途。在整个项目中,您将会经常使用models.pyviews.pyadmin.py这三个文件。

2.2 如何注册应用程序

Django并不会自动识别你的新应用程序。你需要通过将该应用程序添加到《settings.py》文件中的`INSTALLED_APPS`列表中来告知它。

打开《fitness_project/settings.py》,找到`INSTALLED_APPS`列表。在列表末尾添加该应用程序的名称,即`tracker`:

eec90a01-5219-449e-97f8-97465e4ac23f

你会注意到,Django已经自动安装了一些应用程序。这体现了Django“开箱即用”的设计理念——许多常用功能都可以直接使用。

以下是这些应用程序各自的功能简介。

应用程序名称 用途
django.contrib.admin 用于管理内置的管理员控制面板,可通过网页界面来操作数据。
django.contrib.auth 负责处理用户账户、登录系统、权限设置以及密码管理功能。
django.contrib.contenttypes 帮助Django追踪并管理不同模型之间的关系。
django.contribsessions 用于存储用户会话数据,使用户在多次请求之间保持登录状态。
django.contrib.messages 允许显示临时通知信息,如成功或错误提示。
django.contrib.staticfiles 用于管理CSS、JavaScript文件以及图片等静态资源。

现在,Django已经知道你的`tracker`应用程序存在了,在运行项目时也会包含它。

步骤3:如何创建锻炼模型

在Django中,模型是一种Python类,用于定义数据的结构。每个模型都会直接对应数据库中的一张表,模型中的每个属性都会成为该表中的一列。

可以把模型想象成电子表格的蓝图——类名就是电子表格的名称,而每个字段则相当于列标题。每当你保存一次新的锻炼记录时,Django就会在电子表格中添加一行新数据。

3.1 如何定义模型

打开《tracker/models.py》,将其内容替换为以下代码:

from django.db import models

class Workout(models.Model):
    activity = models.CharField(max_length=200)
    duration = models.IntegerField(help_text="持续时间,单位为分钟")
    date = models.DateField()

    def __str__(self):
        return f"{self.activity} - {self.duration}分钟,在{self.date}进行"

让我们来了解一下每个部分的功能:

  • activity = models.CharField(max_length=200) 这个代码用于创建一个文本字段,最多可以存储200个字符。你可以在这里输入锻炼的名称,比如“跑步”或“骑自行车”。

  • duration = models.IntegerField(help_text="持续时间(以分钟为单位)") 这个代码用于创建一个整数字段,用来记录锻炼持续了多少分钟。help_text参数会添加一条提示信息,这些提示会显示在表单和管理员面板中。

  • date = models.DateField() 这个代码用于创建一个日期字段,用来记录锻炼发生的具体时间。

__str__()方法决定了当打印或在管理员面板中显示时,“Workout”对象会以什么样的形式呈现。这样一来,你看到的就不会是像“Workout object (1)”这样的无意义信息,而是“跑步——2025-03-15,持续30分钟。

步骤4:如何应用迁移文件

你已经定义了模型结构,但Django还没有实际创建相应的数据库表。要想完成这一操作,你需要运行迁移命令。

迁移文件是Django将你的Python模型定义转换为数据库指令的一种方式。执行迁移分为两个步骤。

当你修改某个模型时——无论是添加字段、删除字段还是更改字段名称——都需要创建一个新的迁移文件来描述这些变更。你可以使用makemigrations命令来完成这个操作。

之后,你需要使用migrate命令来应用这些迁移文件,Django会自动更新数据库结构以匹配新的模型定义。

这种先检测变更、再应用变更的两步流程,能够确保你能够准确记录数据库结构随时间发生的所有变化。

4.1 如何生成迁移文件

在集成终端中运行以下命令:

python manage.py makemigrations

你应该会看到如下输出结果:

Migrations for 'tracker': tracker/migrations/0001_initial.py 
    + Create model Workout

Django已经检查了你的“Workout”模型,并生成了一个迁移文件,该文件详细说明了如何创建相应的数据库表。如果你想查看这个文件,可以找到它的位置是tracker/migrations/0001_initial.py,不过你不需要手动编辑它。

执行makemigrations命令后生成的文件

4.2 如何应用迁移文件

现在,让Django执行这个迁移命令,从而在数据库中创建相应的表:

python manage.py migrate

在执行迁移过程中,你会看到多行输出信息。因为Django不仅会应用你自定义的迁移文件,还会应用Django内置应用程序所对应的默认迁移文件(比如认证系统、会话管理等)。

该图片展示了应用迁移操作后的结果

当迁移操作完成之后,你的数据库中就会有一个专门用于存储训练记录的表格。

当执行`migrate`命令时,我们可以看到Django实际使用了哪些SQL语句来构建和修改数据库结构。虽然了解这些细节对于创建应用程序来说并非必需,但了解底层发生的操作过程总是有益的。

请运行以下命令:

python manage.py sqlmigrate tracker 001

你应该会得到如下输出结果:

该图片展示了用于查看Django生成的SQL查询语句的命令

你在命令末尾添加的`001`表示迁移的编号,它代表数据库模式的第一个版本。

在实际开发中,你的工作流程通常会是这样的:先修改模型结构,然后运行`makemigrations`命令生成迁移文件,最后执行`migrate`命令将这些变更应用到数据库中。

步骤5:如何在管理面板中注册模型

Django内置了一个功能强大的管理界面。通过这个界面,你可以无需编写任何额外代码,就能以图形化的方式查看、添加、编辑或删除数据库中的记录。在开发过程中,这一功能非常实用,因为你可以快速测试模型并查看数据情况。

但是,默认情况下,Django并不知道:

  • 你想要管理哪些模型

  • 你希望这些模型以什么样的方式显示出来

因此,你需要通过`admin.py`文件来注册这些模型,这样Django才会将它们包含在管理界面中。

5.1 如何将模型添加到管理界面

打开`tracker/admin.py`文件,并添加以下代码:

from django.contrib import admin
from .models import Workout

admin.site.register(Workout)

ad017508-993a-4c28-ade1-8e73fa0c6a4a

这一行代码告诉Django,要将`Workout`模型包含在管理界面中。

5.2 如何创建超级用户

要访问管理面板,你需要一个超级用户账户。运行以下命令来创建一个:

python manage.py createsuperuser

Django会要求你输入用户名、电子邮件地址和密码。请选择容易记住的信息。电子邮件地址是可选的——你可以直接按Enter键跳过这一步骤。

该图像展示了如何通过输入用户名、电子邮件和密码来创建超级用户

5.3 如何访问管理面板

启动开发服务器:

python manage.py runserver

然后在浏览器中访问 http://127.0.0.1:8000/admin/。使用你刚刚创建的用户名和密码进行登录。

你应该会看到 Django 管理面板,其中有一个“Tracker“板块,里面包含了你的“Workouts

该图像展示了 Django 管理面板以及被添加到管理面板的 Tracker 应用程序中的 Worker 模型

试着点击“添加”按钮,创建几个测试用例。这样就可以确认你的模型运行是否正常,然后再继续开发应用程序的其他部分。

该图像展示了一些训练记录(跑步和骑自行车)被添加到管理面板中

步骤 6:如何为应用程序创建视图

在 Django 中,视图是一种 Python 函数或类,它接收 Web 请求并返回相应的 Web 响应。这个响应可以是 HTML 页面、重定向链接、404 错误信息,或是浏览器能够处理的任何其他内容。

视图是应用程序逻辑的核心所在。它们决定了需要获取哪些数据、进行哪些处理,以及最终向用户展示什么内容。

对于这个应用程序来说,你需要创建两个视图:一个用于显示让用户添加训练记录的表单,另一个用于显示所有已保存的训练记录列表。

6.1 如何创建表单类

在编写视图之前,首先需要一个能够处理用户输入数据的 Django 表单。

Django 表单是一种内置的功能,用于处理各种用户输入数据,比如登录表单、联系表单,或是任何收集用户信息的表单。使用 Django,你无需手动编写 HTML 代码、验证用户输入或处理错误信息,而是可以通过结构化的方式一次性完成这些工作。

大多数用户输入数据都是基于你创建的模型,而 Django 可以利用 `ModelForms` 自动根据这些模型生成表单,从而大大提高开发效率。

让我们在 `tracker` 文件夹中创建一个名为 `forms.py` 的新文件,并添加以下代码:

from django import forms
from .models import Workout

class WorkoutForm(forms.ModelForm):

    class Meta:
        model = Workout
        fields = ['activity', 'duration', 'date']
        widgets = {
            'date': forms.DateInput attrs={'type': 'date'],
        }

该图片展示了forms.py文件的路径以及其代码内容

在上面的代码中,ModelForm会根据Workout模型自动生成表单字段。widgets字典告诉Django将日期字段渲染为HTML日期选择器,而不是纯文本输入框。

我们实际上可以看到Django是如何自动创建这些表单的。为此,我们需要进入shell环境。在终端中输入以下命令:

python manage.py shell

该图片展示了激活后的Python shell界面

现在,让我们导入我们刚刚创建的WorkoutForm类。

输入以下代码:

from tracker.forms import WorkoutForm

请注意,在导入表单类时,我们也指定了应用程序的名称

然后创建一个WorkoutForm类的对象,并将其打印出来。

from tracker.forms import WorkoutForm
workoutform = WorkoutForm()
print(workoutform) 

该图片展示了打开Python shell后,可以通过终端执行Python命令的界面

你应该会得到如下输出:

该图片展示了由ModelForm生成的HTML代码

你可以看到,所有的模型字段都被渲染成了HTML表单形式,而日期字段也被设置为type="date"类型,而不是纯文本。

6.2 如何编写视图函数

正如我们上面所讨论的,我们的项目有两个视图函数:一个用于添加锻炼记录,另一个用于显示所有已保存的锻炼记录。

首先,让我们创建一个用于添加锻炼记录的视图函数。在tracker/views.py文件中,输入以下代码:

from django.shortcuts import render, redirect
from .models import Workout

# 用于列出所有锻炼记录的视图函数
def workout_list(request):
    workouts = Workout.objects.all().order_by('-date')
    return render(request, 'tracker/workout_list.html', {'workouts': workouts})

让我们来详细分析这个视图函数的功能:

  • workout_list视图负责显示所有锻炼记录的页面。

  • 它会从数据库中检索所有的Workout对象,按照日期对这些对象进行排序(由于使用了-前缀,因此最新的记录会排在最前面),然后将这个列表传递给名为workout_list.html的模板。

  • render函数会将模板与检索到的数据结合在一起,最终生成完整的HTML页面并返回出来。

要创建用于添加锻炼记录的逻辑,首先需要在导入代码段的末尾添加对Workout表的导入语句。然后,在workout_list视图之后添加以下代码:

from django.shortcuts import render, redirect
from .models import Workout
from .forms import WorkoutForm

# 用于列出所有锻炼记录的视图函数
def workout_list(request):
    workouts = Workout.objects.all().order_by('-date')
    return render(request, 'tracker/workout_list.html', {'workouts': workouts})

# 用于添加锻炼记录的视图函数
def add_workout(request):
    if request.method == 'POST':
        form = WorkoutForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('workout_list')
    else:
        form = WorkoutForm()
    return render(request, 'tracker/add_workout.html', {'form': form})
  • add_workout视图函数既负责显示空表单,也负责处理用户提交的表单数据。

  • 当用户首次访问该页面时,请求方法为GET,因此Django会生成一个空白表单并将其呈现给用户。

  • 当用户填写完表单并点击“提交”按钮时,请求方法变为POST。此时Django会验证用户提交的数据,如果数据无误,则会将这些数据保存到数据库中,并将用户重定向到锻炼记录列表页面。

  • 如果提交的数据无效,Django会重新显示表单,并在表单中显示错误信息。

以下是完整的视图函数代码:

from django.shortcuts import render, redirect
from .models import Workout
from .forms import WorkoutForm

# 用于列出所有锻炼记录的视图函数
def workout_list(request):
    workouts = Workout.objects.all().order_by('-date')
    return render(request, 'tracker/workout_list.html', {'workouts': workouts})

# 用于添加锻炼记录的视图函数
def add_workout(request):
    if request.method == 'POST':
        form = WorkoutForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('workout_list')
    else:
        form = WorkoutForm()
    return render(request, 'tracker/add_workout.html', {'form': form})

该图片展示了views.py中的完整代码,同时附有关于如何添加锻炼记录的说明

步骤7:如何创建模板文件

模板文件是HTML格式的文件,Django会使用动态数据来填充这些文件。它们构成了你的应用程序的用户界面部分,也就是用户在实际浏览器中看到的内容。

7.1 如何设置模板目录

Django会在每个应用程序内部的templates文件夹中查找模板文件。请在你的tracker应用程序中创建如下文件夹结构:

tracker/templates/tracker

“tracker”这个双层文件夹名称看起来可能有些冗余,但实际上这是 Django 中的一种约定,被称为模板命名规范。这种规范能够避免在多个应用程序中使用相同文件名来存储模板时出现命名冲突的问题。

该图片展示了模板文件夹的目录结构

7.2 如何创建锻炼列表模板

创建一个名为tracker/templates/tracker/workout_list.html的文件,然后添加以下代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    我的锻炼记录
    
    

    
        

我的锻炼记录

+ 添加新的锻炼记录 {% if workouts %} {% for workout in workouts %}
{{ workout.activity }}
{{ workout.duration }}分钟
{% endfor %} {% else %}

目前还没有任何锻炼记录。请先添加一条吧!

{% endif %}

这里有几点值得注意:

如果你仔细观察HTML代码,就会发现一些用大括号包裹起来的特殊标签({% %}{{ }})。可以把它们看作是Django的专用指令。

当你想要在页面上直接显示某段数据时,就需要使用双大括号({{ }})。

而当你需要让Django执行某些操作或应用逻辑(比如运行循环或检查条件)时,则应该使用大括号和百分号的组合({% %})。

这些标签使我们能够将来自Python后端的动态数据直接插入到原本静态的HTML代码中。

让我们来看一下workout_list.html这个代码片段:

<body>
    <div class="container">
        <h1>我的锻炼记录</h1>
        <a href="{% url 'add_workout' %}" class="add-link">+ 添加新的锻炼记录</a>
        {% if workouts %}
            {% for workout in workouts %}
                <div class="workout-card">
                    
{{ workout.activity }}</div>
{{ workout.duration }}分钟</div>
{{ workout.date }}</div> </div> {% endfor %} {% else %}
尚未记录任何锻炼记录。请先添加一条吧!</p>>
>
{% endif %} </div> </body>

这张图片展示了workout_list.html中的主体部分,重点显示了Django模板标签

这里有几点值得注意。

在主标题的正下方,你会看到这一行代码:
<a href="{% url 'add_workout' %}">

与直接编写硬编码的链接不同(例如href="/add-workout/"),Django使用{% url %}标签来动态生成链接。你只需要提供路由的名称(在这个例子中是add_workout),Django就会自动计算出正确的URL路径。

如果你后来修改了Python代码中的URL结构,Django会自动更新这些链接。因此,你根本不需要去翻找HTML文件来修复失效的链接!

这张图片突出了用于生成动态链接的代码部分

{% if workouts %}这个代码块用于检查是否有需要显示的锻炼记录。如果列表为空,它就会显示一条友好的提示信息,而不会呈现空白页面。

{% for workout in workouts %}这个循环会遍历列表中的每一项训练记录,并为每项记录生成一张对应的卡片。双大括号{{ workout.activity }}用于将各项字段的值插入到HTML代码中。

f75b5c7e-5cd0-457c-901f-e489c26a8175

在循环内部,你会看到如下这样的标签结构:

  • {{ workout.activity }}

  • {{ workout.duration }}

  • {{ workout.date }}

当Django遍历列表中的每一项训练记录时,它会使用点表示法来提取该记录的详细信息,比如活动类型(例如“跑步”)、持续时间(“30分钟”)以及日期(“3月30日”),然后将这些信息直接显示在网页上,让用户能够看到。

7.3 如何创建添加训练记录的模板

创建一个名为tracker/templates/tracker/add_workout.html的文件,并在其中添加以下代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    记录训练信息
    
    
    
        

记录训练信息

{% csrf_token %}
{{ form.activity }} {% if form.activity.errors %}
{{ form.activity.errors }}
{% endif %}
{{ form.duration }} {% if form.duration.errors %}
{{ form.duration.errors }}
{% endif %}
{{ form.date }} {% if form.date.errors %}
{{ form.date.errors }}
{% endif %}
取消

在之前的模板中,我们学习了如何展示数据。现在,我们要来看一种能够实际收集数据的表单。在网页开发中,手动处理表单可能会显得很繁琐,但Django提供了一些强大的模板标签来帮助我们完成这项工作。

让我们来看看支撑这个表单的、专门为Django设计的逻辑:

首先,在打开`

`标签之后,你会看到一行非常重要的代码:`{% csrf_token %}`。每当你使用“POST”方法将数据提交到服务器时,恶意网站就有可能截获或伪造这些请求。

通过添加这一行`{% csrf_token %}`,你是在告诉Django为这个表单生成一个唯一且隐藏的安全密钥。当用户点击“保存锻炼记录”时,Django会检查这个密钥,以确保请求是合法的。如果你忘记了添加这条代码,Django就会直接拒绝你的表单提交!

以下是具体的代码示例:

“`html

{% csrf_token %}


{{ form.activity }}
{% if form.activity.errors %}

{{ form.activity.errors }}

{% endif %}


{{ form.duration }}
{% if form.duration.errors %}

{{ form.duration.errors }}

{% endif %}


{{ form.date }}
{% if form.date.errors %}

{{ form.date.errors }}

{% endif %}


取消

“`

现在,我们来谈谈如何自动生成表单字段。我们不需要手动为活动类型、持续时间以及日期这些字段编写所有的``标签,而是让Django利用模板标签`{{ }}`来完成这项工作。

每个`{{ form.activity }}`、`{{ form.duration }}`和`{{ form.date }}`标签都会渲染出相应的表单输入字段。Django会根据模型和表单的定义,自动处理这些输入字段的HTML属性、类型以及验证逻辑。此图片展示了用于自动生成HTML表单的代码

在每个输入字段下方,都会显示验证提示信息。如果用户提交了无效的数据——比如在应该输入数字的字段中输入了文本,系统就会显示这些提示信息。用户难免会犯错,他们可能会忽略必填字段,或者将文本输入到应输入数字的字段中。幸运的是,Django会自动验证数据,并在出现问题时向用户反馈错误信息。

在每个输入字段下方,我们使用了如下代码块:
{% if form.activity.errors %}

这段代码会检查一个简单的条件:用户是否在这个特定字段中犯了错误?如果Django发现“activity”这个输入字段存在问题,就会执行if语句块,并通过{{ form.activity.errors }}来显示具体的错误信息(例如“此字段是必填的”),这些信息会直接显示在输入框下方。

此图片展示了错误提示信息的显示方式

你可能会注意到,这两个模板中都使用了内联CSS,而不是单独的样式表。对于像这样的小型项目来说,内联样式可以让代码更加简洁、易于维护。而在大型项目中,你可以使用Django的静态文件系统来专门管理CSS文件。

步骤8:如何连接URL

你已经创建了视图和模板,但Django还不知道应该在什么时候使用它们。你需要将特定的URL与相应的视图函数关联起来,这样当用户在浏览器中访问某个地址时,系统就能调用正确的视图函数。

8.1 如何创建应用级别的URL

创建一个名为tracker/urls.py的新文件,并添加以下代码:

from django.urls import path
from . import views

urlpatterns = [ 
    path('', views.workout_list, name='workout_list'), 
    path('add/', views.add_workout, name='add_workout'), 
]

每个`path`函数都需要接受三个参数。

第一个参数是表示URL模式的字符串;如果这个字符串为空,则表示该URL对应于应用的根目录。

第二个参数是在访问该URL时需要调用的视图函数。

第三个参数是一个名称,你可以在代码的其他地方使用这个名称来引用这个URL,比如在之前使用的{% url %}模板标签中。

现在,你的应用级URL已经设置好了,下一步就是将它们连接到主项目中,这样Django才能知道从哪里开始处理请求。可以把这想象成是将一张较小的地图(你的应用)与一张较大的地图(你的项目)连接起来,这样所有部分就能顺利协同工作了。

打开fitness_project.urls.py文件,将其更新为包含你应用的URL:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('tracker.urls')),
]

include()函数告诉Django,每当有人访问你的网站时,都要去查看tracker/urls.py文件中定义的URL规则。使用空字符串作为前缀,意味着你的tracker应用会处理站点根目录下的所有请求。

下面是请求在URL系统中流动的具体过程。

当有人访问http://127.0.0.1:8000/add/时,Django会首先检查fitness_project/urls.py文件。由于请求路径以空字符串开头,因此Django会将处理任务交给tracker/urls.py文件。在tracker/urls.py文件中,Django会匹配到add/这个路径,并调用add_workout视图函数来处理请求。

该图片展示了请求如何在系统中流动

步骤9:如何在本机测试应用程序

此时,你的应用已经具备了正常运行所需的所有条件。让我们来测试一下吧。

运行以下命令启动开发服务器:

python manage.py runserver

打开浏览器,访问http://127.0.0.1:8000/。你应该会看到一个标题为“我的锻炼记录”的页面,以及一个写着“+ 记录锻炼内容”的按钮。

该图片展示了‘我的锻炼记录’页面及记录锻炼内容的按钮

点击那个按钮,你应该会看到一个包含活动类型、持续时间以及日期等字段的锻炼记录表单。

该图片展示了一个空的锻炼记录表单

填写一些测试数据:

  • 活动类型:跳绳

  • 持续时间:25分钟

  • 日期:从日期选择器中选取今天的日期

点击“保存锻炼记录”,你应该会回到锻炼记录页面,而你新添加的锻炼记录会以卡片的形式显示在那里。

该图片展示了在添加新的锻炼项目后生成的锻炼计划列表

试着添加一些包含不同活动和日期的训练计划。请确保它们都能以正确的顺序显示在列表页面上(最新的内容排在最前面)。

现在也是进行实验的好时机。可以尝试提交一些缺少某些字段的表单,看看Django会如何处理这些验证错误。

该图片展示了一个未完成填写的表单被提交时的情景,以及相应的错误信息

也可以尝试访问http://127.0.0.1:8000/admin/来查看管理面板中是否也显示了你的训练计划。

该图片展示了在Django管理面板中添加的训练计划

如果一切都能按预期运行,那么你就可以将你的应用程序发布到互联网上了。

步骤10:如何为部署做准备

在本地主机上运行应用程序确实非常适合开发阶段,但其他人是无法看到它的。而部署意味着将你的应用程序放置在一个可以从互联网上的任何地方访问的服务器上。

在进行部署之前,你需要对项目的配置文件进行一些修改。

10.1 如何为生产环境更新配置

打开`fitness_project/settings.py`文件,然后进行以下更改。

首先,将`DEBUG`的值设置为`False`。

在开发阶段,将`DEBUG = True`会显示详细的错误信息,这些信息有助于你解决问题。但在生产环境中,如果出现错误,这些错误信息可能会向任何看到它们的人暴露有关你的代码和服务器的敏感信息。

该图片展示了如何在settings.py文件中将DEBUG设置为False

接下来,更新`ALLOWED_HOSTS`设置,以便包含PythonAnywhere的域名

这个设置告诉Django哪些域名被允许用来提供你的应用程序服务。请将`yourusername`替换为你在下一步中实际创建的PythonAnywhere用户名。

ALLOWED_HOSTS = ['yourusername.pythonanywhere.com']

该图片展示了更新后的allowed_host列表,其中添加了pythonanywhere域名

最后,添加一个`STATIC_ROOT`设置,这样Django就知道应该将你的静态文件(CSS、JavaScript、图片等)保存在哪个位置,以便在生产环境中使用。

import os
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')

该图片展示了用于收集静态文件的代码

这些就是进行基本部署时所需进行的最低限度的修改。

💡 对于处理真实用户数据的生产环境应用程序,您还需要设置一个安全的SECRET_KEY,配置像PostgreSQL这样的数据库,并启用HTTPS。但对于学习项目来说,这些修改已经足够了。

步骤11:如何将您的Django应用部署在PythonAnywhere上

PythonAnywhere是一个专为Python Web应用程序设计的托管平台。它提供了免费套餐,非常适合初学者使用;同时,它会处理许多服务器配置工作,而这些工作如果由用户自己来完成会非常复杂。

11.1 如何创建PythonAnywhere账户

请访问pythonanywhere.com注册一个免费的“初学者”账户。请记住您选择的用户名,因为您的应用程序将会托管在yourusername.pythonanywhere.com这个地址上。

该图片展示了PythonAnywhere的首页

现在,请登录该网站,填写用户名、电子邮件地址和密码,然后选择免费套餐进行注册。

该图片展示了PythonAnywhere提供的不同等级的托管服务

11.2 如何上传您的项目文件

登录成功后,您有两种方法可以将项目文件上传到PythonAnywhere上。

选项A:使用Git进行上传

如果您的项目存储在Git仓库中,请先通过点击PythonAnywhere控制面板中的“Consoles”再选择“Bash”来打开Bash控制台,然后克隆您的仓库:

git clone https://github.com/yourusername/fitness-tracker.git

在本教程中,我们不会使用Git,而是选择第二种方法。

选项B:手动上传文件

首先,请找到您电脑上的项目文件夹,并将其压缩成文件。

重要提示:在压缩文件之前,请务必先复制一份项目文件,同时删除“venv”和“pycache”这两个文件夹。该图像显示了项目文件夹被压缩的过程

请导航到您的主目录,点击“上传文件”选项,然后上传压缩后的文件。

该图像显示了压缩文件正在被上传到PythonAnywhere服务器上

接下来我们需要解压这个压缩文件。为此,请进入“控制台”选项卡,然后点击“Bash控制台”。

该图像显示了“控制台”选项卡以及“Bash控制台”选项

Bash控制台应该已经打开了。现在在控制台中输入以下命令来解压文件夹:

unzip fitness-tracker.zip

该图像显示了解压命令执行后的结果

11.3 如何在PythonAnywhere中设置虚拟环境

请从PythonAnywhere的控制面板中打开Bash控制台。导航到您的项目目录,然后创建一个新的虚拟环境:

cd fitness-tracker

该图像显示了切换到fitness-tracker目录的过程

输入以下命令来安装虚拟环境,然后激活该虚拟环境:

python3 -m venv venv

source venv/bin/activate

该图像显示了虚拟环境的创建与激活过程

现在,像之前一样,使用pip install django命令来安装Django吧:

该图像显示了Django的安装过程

11.4 如何在PythonAnywhere中运行迁移操作并创建超级用户

<当您仍处于激活了虚拟环境的 Bash 控制台中时,请运行相关命令以便在服务器上创建数据库表:>

python manage.py makemigrations

python manage.py migrate

python manage.py createsuperuser

该图片显示了执行“makemigrations”和“migrate”命令的过程
该图片显示了超级用户创建的过程

11.4 如何在 Pythonanywhere 中配置 Web 应用程序

进入 PythonAnywhere 的控制面板,选择“Web”选项卡,然后点击“添加新的 Web 应用程序”。按照设置向导的指示进行操作:

该图片显示了“Web”选项卡以及“添加新的 Web 应用程序”的按钮

在填写域名步骤中,点击“下一步”(请记住,免费版本使用的域名是 yourusername.pythonanywhere.com)。

该图片显示了用于指定域名的 Web 控制面板

选择“手动配置”选项(而不是“Django”——手动配置能让你拥有更大的控制权)。

该图片突出了应选择的“手动配置”选项

然后选择与你已安装的 Python 版本相匹配的版本。在我的例子中,使用的版本是 3.13,所以我选择了 3.13。

该图片显示了所选择的 Python 版本

点击“下一步”按钮,系统将会创建一个 WSGI(Web 服务器网关接口)。

该图片显示了在创建 Web 应用程序之前的最终页面

通过以上步骤,我们就已经创建完成了 Web 应用程序:

该图片显示了 Web 应用程序创建完成的最终结果

在完成 Web 应用程序的配置后,你还需要再做两件事:

  • 设置虚拟环境路径

  • 配置WSGI文件

11.5 如何设置虚拟环境路径

在“Web”选项卡中,向下滚动至“Virtualenv“部分,然后输入你的虚拟环境的路径。该路径应该如下所示:

/home/yourusername/fitness-tracker/venv

图片展示了所设置的虚拟环境路径

11.6 如何配置WSGI文件

仍在“Web”选项卡中,向下滚动至代码部分,然后点击WSGI配置文件的链接:

图片展示了代码部分及WSGI配置文件的路径

删除文件中的所有内容,将其替换为以下代码,然后保存文件:

import os
import sys
path = '/home/prabodhtuladhardev/fitness-tracker' #请替换为你的用户名
if path not in sys.path:
    sys.path.append(path)

os.environ['DJANGO_SETTINGS_MODULE'] = 'fitness_project.settings'

from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()

图片展示了编辑后的wsgi.py文件以及保存按钮

11.7 如何配置静态文件

仍在“Web”选项卡中,向下滚动至“静态文件”部分。添加以下条目:

  • URL: /static/

  • 目录: /home/yourusername/fitness-tracker/staticfiles

图片展示了“Web”选项卡中的静态文件设置部分

之后回到Bash控制台,运行以下命令:

python manage.py collectstatic

图片展示了执行collectstatic命令后的结果

此命令会将所有静态文件复制到staticfiles目录中,这样PythonAnywhere就可以直接提供这些静态文件了。

该图片显示了名为‘static files’的文件夹已被创建

返回“Web”选项卡,然后点击顶部的绿色“重新加载”按钮。这样就能使用所有新的配置重新启动你的应用程序。

该图片显示了带有‘重新加载’按钮的Web选项卡

11.8 如何查看你的在线应用程序

打开一个新的浏览器标签页,访问 https://yourusername.pythonanywhere.com。你应该能看到自己的健康追踪工具在互联网上实时运行。

试着添加一次锻炼记录吧。

该图片显示了在Python Anywhere中打开的锻炼记录列表视图

访问管理员面板,地址为 https://yourusername.pythonanywhere.com/admin/

该图片显示了在Python Anywhere中打开的Django管理员界面

现在,一切应该都能像在本地机器上一样正常运行了,但任何拥有这个链接的人都可以访问它。

这是一个非常重要的里程碑——你已经成功将一个Django应用程序部署到了线上。可以把这个链接分享给朋友,或者发布在编程社区里。看到自己的作品真正出现在互联网上,绝对是学习编程过程中最令人振奋的经历之一。

常见的错误及解决方法

即使你仔细遵循了每一个步骤,也还是可能会出现问题。以下是初学者们经常遇到的常见问题以及相应的解决办法。

“ModuleNotFoundError: 未找到名为‘django’的模块”——这通常意味着你的虚拟环境没有激活。请运行 source venv/bin/activate(macOS/Linux系统)或 venv\Scripts\activate(Windows系统),然后重新尝试。在Python Anywhere平台上,要确保“Web”选项卡中的virtualenv路径指向正确的位置。

“DisallowedHost” 错误——你可能忘记在 settings.py 文件中将你的域名添加到 ALLOWED_HOSTS 列表中,或者其中存在拼写错误。请仔细检查该域名是否与你的Python Anywhere网址完全一致。

在生产环境中静态文件无法加载——请确保你已经运行了 python manage.py collectstatic 命令,并且Python Anywhere平台上的静态文件映射设置是正确的。同时,也要确认 settings.py 文件中确实设置了 STATIC_ROOT 变量。

“没有这样的表”或出现迁移错误——很可能是在将项目克隆或上传到PythonAnywhere之后,忘记了运行`python manage.py migrate`命令。请在Bash控制台中执行这个命令。

更改没有在PythonAnywhere上显示——在进行任何代码修改后,必须点击“Web”选项卡上的“重新加载”按钮。PythonAnywhere不会自动检测文件的变化。

图片展示了Web选项卡和重新加载按钮

如何改进这个项目

你开发的这款健身追踪工具本来就被设计得相当简单。这其实是一种优势,而非局限。一个功能完备但结构简单的项目,是进一步学习的理想基础。

以下是一些扩展它的建议。

  1. 添加用户认证功能:目前,任何访问该网站的人都能看到相同的锻炼数据。Django内置了认证系统,可以支持注册、登录和登出功能。这样一来,每个用户都可以拥有自己私人的锻炼记录列表。

  2. 允许用户编辑和删除锻炼记录。现在,一旦锻炼记录被保存下来,就无法通过界面直接修改或删除它(虽然可以通过管理员面板操作,但主应用程序中无法实现)。可以尝试创建新的视图和模板来实现这些功能。

  3. 添加锻炼类别或标签。让用户能够将他们的锻炼分为“有氧运动”、“力量训练” “柔韧性训练”等类别。这需要为模型添加新字段,或者创建一个单独的“Category”模型,并通过外键关系将其与主模型关联起来。

  4. 添加图表和进度跟踪功能。可以使用Chart.js这样的JavaScript图表库来展示锻炼数据的变化趋势。例如,可以制作出每周总锻炼时间的条形图。

  5. 使用Django REST Framework构建API。如果你想学习如何开发API,可以尝试安装Django REST Framework,并为你的锻炼记录功能创建相应的API接口。这样就可以开发移动应用或独立的前端程序,与后端的Django服务进行交互了。

每一项改进都会让你在已有基础之上,进一步了解Django的相关知识。

结论

你已经使用Django成功开发出了一款功能完备的健身追踪网站应用,并将其部署到了互联网上。这确实是一项了不起的成就。

在这个过程中,你了解了Django项目和应用的结构,知道了模型是如何定义数据结构的,迁移机制又是如何将这些模型转换为数据库表的,视图如何处理应用程序的逻辑逻辑,模板如何生成动态HTML页面,以及URL是如何将所有这些组件联系在一起的。同时,你也亲自完成了在PythonAnywhere上的整个部署流程。

这些就是Django开发的核心要素。你在这里练习过的那些步骤——定义模型、创建表单、编写视图函数、构建模板以及配置URL链接——无论项目有多复杂,都是你在每一个Django项目中都会用到的基本方法。

巩固所学知识的最佳方式就是不断实践。你可以尝试上述提到的某一种改进方法,或者开始一个全新的项目。无论是记录卡路里摄入量、习惯养成情况、支出明细,还是写个人日记,这些应用都可以运用相同的Django开发理念,只不过所使用的模型和视图函数会略有不同而已。

]]>
如何为大型 Next.js 应用程序构建可重用的架构 http://www.cheeli.com.cn/articles/how-to-build-reusable-architecture-for-large-next-js-applications/ Sat, 04 Apr 2026 20:20:29 +0000 http://www.cheeli.com.cn/?p=21109 Read More]]> 每个 Next.js 项目都是以相同的方式开始的:你运行 `npx create-next-app`,编写一些页面代码,或许再添加一两个 API 路由,这时一切看起来都井井有条。

但随后项目开始发展,功能越来越多。可能会出现第二个应用程序,或者一个独立的管理员控制面板、一个营销网站,又或者一个专为移动端设计的 API。突然间,你发现自己需要在不同的代码仓库之间复制组件,重复编写业务逻辑,还会为这些认证功能的归属问题争论不休,同时不禁会问自己:到底哪里出了问题?

答案几乎总是与架构有关——或者说,是缺乏合理的架构设计。这里所指的架构并不是那种记录在 Notion 文档中的抽象概念,而是真正融入你的代码结构、模块划分方式,以及你在项目开始时就使用的工具中的架构。

这篇文章将为你提供实用的建议,帮助你在 Next.js 中构建层次分明、可复用的架构。

你将学习到关于 App Router 的组件布局规则,如何根据功能需求设计可扩展的代码文件夹结构,如何利用 Turborepo 在不同应用程序之间共享逻辑,如何使用 Server Components 明确界定数据获取的范围,如何制定与你的代码层次结构相匹配的测试策略,以及如何配置 CI/CD 流程,以确保只构建和测试那些真正发生了变化的部分。

读完这篇文章后,你将获得一个可以实际应用的蓝图,而不仅仅是一个可供欣赏的设计方案。

目录

核心问题:无意识的耦合

当某个组件直接访问全局状态存储时,当一个页面从三个不同的目录中导入功能模块时,当你的认证逻辑分散在 `/lib`、`/helpers` 和 `/utils` 等文件夹中且没有明确的负责者时,每一个文件都会过度了解其他所有文件的运作方式。

这个应用程序仍然可以正常运行。但如今,修改其中一个部分就会导致另外三个部分出现故障;新功能的集成需要花费整整一周的时间;而添加第二个应用程序时,往往意味着需要复制第一个应用程序中的一半代码。

分层架构通过为所有内容指定明确的位置,并使这些位置具有特定的意义,从而解决了这些问题。

第一层:应用路由器与代码组织结构

Next.js 13及后续版本引入了应用路由器,这种基于文件系统的路由模型具有非常强大的功能:它允许将与某个路由相关的所有内容都放在该路由对应的文件夹中。

在应用路由器出现之前,页面文件存放在/pages目录下,组件文件存放在/components目录下,而数据获取逻辑则分散在各处。应用路由器的出现改变了这一状况——现在,一个路由段可以将其布局、加载状态、错误处理机制、服务器交互逻辑,甚至本地组件都集中在同一个文件夹中。

代码组织的真正含义

/dashboard这个路由为例,在应用路由器的架构下,其对应的文件夹结构可能如下所示:

app/
  dashboard/
    page.tsx              # 该路由的入口文件
    layout.tsx            # 专门用于仪表盘的布局文件
    loading.tsx           # 显示加载状态的文件
    error.tsx             # 处理错误情况的文件
    components/
      StatsCard.tsx       # 仅在仪表盘中使用
      ActivityFeed.tsx
    lib/
      queries.ts          # 专为该路由设计的数据获取逻辑
      formatters.ts       | 用于仪表盘的数据格式化函数

关键在于:StatsCard.tsxqueries.ts并不属于整个应用程序,它们只属于/dashboard这个路由段。因此,当你删除或重构仪表盘相关代码时,只会影响到这个文件夹,而不会影响其他部分的功能。

这就是所谓的“代码组织”。这个概念本身并不新鲜,但应用路由器使得在Next.js中采用这种结构变得非常自然、便捷。

就近原则

一个很好的设计准则是:文件应该尽可能地放在它被使用的位置附近。如果某个文件只在一个路由中被使用,那么它就应该放在这个路由对应的文件夹中;如果它被同一个父路由段下的两个不同路由所使用,那么它的位置应该上升一级;而如果它在整个应用程序中都被使用,那么它就应该被放在一个共享的文件夹中。

app/
  (marketing)/          | 营销相关路由组
    layout.tsx          | 营销页面共用的布局文件
    page.tsx
    about/
      page.tsx
  (dashboard)/
    layout.tsx          | 应用程序页面共用的布局文件
    dashboard/
      page.tsx
    settings/
      page.tsx

通过将相关文件放在同一个文件夹中,我们可以方便地在不同的路由段之间共享布局资源,而不会导致URL结构变得复杂。这种设计方式能够清晰地划分不同功能模块,使得营销页面和应用页面可以使用完全不同的界面样式,而无需通过复杂的URL技巧来实现这一点。

第二层:基于功能的文件夹结构

代码组合处理的是路由层面的问题。但大型应用程序会遇到一些跨领域的需求——这些需求既不属于任何特定的路由,也不属于通用的工具函数。

大多数项目正是在这里出现问题:/components文件夹变成了杂乱无章的存储空间,/lib则成了垃圾文件的收纳处,而且没有人能就useAuth应该放在哪里达成一致。

基于功能的文件夹结构能够为这种混乱带来秩序。

按领域而非文件类型进行组织

不要按照文件的“类型”来对它们进行分类,而应该根据它们“实现的功能”来分组。

src/
  features/
    auth/
      components/
        LoginForm.tsx
        AuthGuard.tsx
      hooks/
        useAuth.ts
        useSession.ts
      lib/
        tokenStorage.ts
        validators.ts
      types.ts
      index.ts            # 公共API,只导出其他部分所需的内容

    billing/
      components/
        PricingTable.tsx
        SubscriptionBadge.tsx
      hooks/
        useSubscription.ts
      lib/
        stripe.ts
      types.ts
      index.ts

    notifications/
      ...

每个功能模块都是一个独立的单元。它拥有自己的组件、钩子函数、工具函数以及类型定义。最重要的是,它还有一个主文件index.ts,这个文件定义了它的公共API——即应用程序的其他部分可以被允许导入的内容。

通过主文件出口来界定各模块的边界

index.ts文件是必不可少的。正是这个文件确保了各个功能模块之间不会相互干扰、发生混乱。

// features/auth/index.ts
export { LoginForm } from './components/LoginForm';
export { AuthGuard } from './components/AuthGuard';
export { useAuth } from './hooks/useAuth';
export type { AuthUser, AuthState } from './types';

// 不被导出,属于内部实现细节:
// tokenStorage.ts, validators.ts

现在,应用程序的其他部分应该从@/features/auth路径导入相关内容,而绝不应该从@/features/auth/lib/tokenStorage路径导入。如果你修改了内部存储令牌的方式,那么应用的其他部分也不会受到影响。这就是封装的本质——它不仅仅是一种理论原则,更是通过文件夹结构来强制实现的。

共享资源与功能模块

并不是所有的内容都适合被纳入某个功能模块中。一些真正通用的工具函数,比如cn()这样的辅助函数、日期格式化工具或基础HTTP客户端,应该被放在共享层中:

src/
  shared/
    components/
      Button.tsx
      Modal.tsx
      Spinner.tsx
    hooks/
      useDebounce.ts
      useMediaQuery.ts
    lib/
      http.ts
      dates.ts
    ui/              # shadcn/ui或设计系统组件

规则是:shared/层对任何具体的功能模块都一无所知;各个功能模块可以从shared/层导入所需资源,但shared/层永远不会从某个功能模块中导入内容。

第三层:使用Turborepo的单仓库架构(在多个应用中实现代码共享)

单仓库架构在初期能发挥很大作用,但大多数团队最终都会开发出多个应用程序:一个面向客户的Next.js应用、一个管理面板、一个独立的营销网站,或许还有一些API服务。

问题就出现了:如何在不进行复制粘贴的情况下在这些应用之间共享代码呢?

答案就是使用包含共享组件的单仓库架构,而目前对于使用Next.js的团队来说,Turborepo正是实现这一目标的最佳工具。

单仓库架构的结构

一个结构合理的Turborepo仓库应该如下所示:

my-platform/
  apps/
    web/              # 面向客户的Next.js应用
    admin/            # 内部管理面板(同样使用Next.js技术)
    marketing/        # 营销网站
  packages/
    ui/               # 共享组件库
    config/           # 共享的ESLint、TypeScript及Tailwind配置文件
    auth/             # 共享的身份验证相关工具和类型定义
    database/         # Prisma客户端及查询辅助函数
    utils/            # 通用实用工具
  turbo.json
  package.json        # 根目录工作区配置文件

apps/文件夹中存放可部署的应用程序;packages/文件夹则包含各应用程序所依赖的共享代码。这些应用程序并不会直接相互导入代码,所有代码共享都通过packages/文件夹来完成。

如何创建共享包

一个共享包其实就是包含一个package.json文件的文件夹,其他工作区的成员都可以依赖这个包。

// packages/ui/package.json
{
  "name": "@my-platform/ui",
  "version": "0.0.1",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts"
  }
}
// packages/ui/src/index.ts
export { Button } from './Button';
export { Modal } from './Modal';
export { Card } from './Card';

现在,你的各个应用程序就可以像使用普通的npm包一样来使用这个共享包了:

// apps/web/package.json
{
  "dependencies": {
    "@my-platform/ui": "*"
  }
}
// apps/web/app/dashboard/page.tsx
import { Card, Button } from '@my-platform/ui';

只要在packages/ui文件夹中修改某个组件的代码,所有使用该组件的应用程序都会自动得到更新,无需进行任何复制粘贴操作,也不会出现代码不一致的情况。

重要提示:由于这个共享包直接引用了TypeScript源文件(而非编译后的结果),因此每个使用它的Next.js应用都必须告诉打包工具对其进行类型转换。你可以在自己的Next.js配置文件中添加相关设置:

// apps/web/next.config.ts
const config: import('next').NextConfig = {
  transpilePackages: ['@my-platform/ui', '@my-platform/auth', '@my-platform/utils'],
};

export default config;

如果没有这个设置,构建过程就会因为语法错误而失败。默认情况下,Next.js并不会自动将 `node_modules` 目录中的包或工作区中依赖的库进行转译。另一种方法是将每个包都编译到 `dist/` 目录中,并将它们的 `exports` 对象设置在该目录下,但这种方法会导致每个包都需要经过额外的构建步骤,从而降低开发效率。对于内部使用的单仓库项目来说,使用 `transpilePackages` 这个选项才是更简单、更可行的解决方案。

`turbo.json` 构建流程

Turborepo 的真正优势在于它的构建流程。它能够理解你的包与应用程序之间的依赖关系,缓存构建结果,并在可能的情况下并行执行各项任务。

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "type-check": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

`^build` 这一语法表示:在构建某个包之前,必须先构建其所有依赖项。因此,如果 `apps/web` 依赖于 `packages/ui`,Turborepo 会确保在开始构建 `apps/web` 之前先构建 `packages/ui`。通过远程缓存机制,如果 `packages/ui` 没有发生变化,Turborepo 就会完全跳过重新构建它的步骤,即使是在不同的持续集成环境中或不同团队成员的机器上也是如此。

包与应用程序的区别

一个很有用的区分方法:

位于 `packages/` 目录下 位于 `apps/` 目录下
设计系统 / 用户界面基础组件 路由定义
认证相关工具与类型 特定于应用程序的布局代码
数据库客户端及查询逻辑 针对特定功能的页面代码
通用的 TypeScript 配置文件 API 路由处理函数
数据分析相关抽象层 特定于环境的配置信息
通用钩子函数(如 `useDebounce`) 特定于应用程序的业务逻辑代码

如果两个应用程序都需要相同的逻辑,那么这种逻辑应该被放在一个包中;而如果只有其中一个应用程序需要这种逻辑,那么它就应该保留在那个特定的应用程序中——即使你认为另一个应用程序将来也可能需要它。过早地进行抽象化处理与完全不进行抽象化处理一样有害。

第 4 层:服务器组件与数据获取边界

App Router 的服务器组件模型可以说是 Next.js 所推出过的最具架构意义的变更,同时也是最容易被误解的变更之一。

大多数开发者将其视为一种性能优化手段。确实如此,但更重要的是,它实际上代表着一个架构上的边界。理解这一边界的定位,并有意识地围绕这一边界进行设计,才能让可扩展的 App Router 代码库与那些与框架相冲突的代码库区分开来。

两种不同的“世界”模型

App Router 中的每一个组件都属于以下两个“世界”之一:

服务器组件(默认设置)仅在服务器端运行。它们可以直接获取数据、访问数据库、读取环境变量,同时还能减少发送到浏览器的 JavaScript 代码量。不过,它们无法使用浏览器提供的 API、`useState`、`useEffect` 或事件处理函数。

客户端组件(使用 `'use client'` 语法)在浏览器中运行(在服务器端渲染/数据同步过程中也会执行)。它们可以使用钩子、处理事件,并访问浏览器的 API,但无法直接 await 服务器端的资源。

指令 `'use client'` 并不意味着“这些组件仅在浏览器中运行”,而是表示“这里是服务器与客户端交互开始的分界点”。任何被客户端组件 导入 的模块都会成为客户端代码包的一部分。

而通过 children 等属性 传递给客户端组件的服务器端组件 仍然保持其仅适用于服务器端的特性:它们在服务器上被渲染成 HTML 数据并传输给客户端,因此不会被包含在客户端代码包中。正是这种区别使得下面的组合模式能够正常工作。

划分边界

我们的目标是将使用 `'use client'` 的界限尽可能地向页面结构的底层延伸,将数据获取和复杂逻辑留在服务器端,而只将真正的交互功能留给客户端组件。

在实际开发中效果很好的一个模式如下:

// app/dashboard/page.tsx,服务器端组件
// 获取数据,无需使用 'use client' 指令
import { getMetrics } from '@/features/analytics/lib/queries';
import { MetricsDashboard } from './components/MetricsDashboard';

export default async function DashboardPage() {
  const metrics = await getMetrics();   // 直接调用数据库接口,无需进行 API 请求
  return ;
}
// app/dashboard/components/MetricsDashboard.tsx,服务器端组件
// 组织页面布局,将交互功能委托给客户端组件
import { StatsCard } from './StatsCard';
import { ChartSection } from './ChartSection';

export function MetricsDashboard({ data }) {
  return (
    
); }
// app/dashboard/components/ChartSection.tsx,客户端组件
// 需要浏览器 API 的交互式图表
'use client';

import { useState } from 'react';
import { LineChart, RangeSelector } from '@my-platform/ui';

export function ChartSection({ points }) {
  const [range, setRange] = useState('7d');
  return (
    
); }

数据从服务器端流向客户端,且这一流程是单向的:服务器负责执行耗时较长的操作(如数据库查询),并将可序列化的数据作为属性传递给客户端;客户端收到的则是已经准备好用于渲染的数据集——既没有加载提示,也没有额外的客户端请求流程。

将数据获取与路由结合使用

服务器端组件提供了一个非常强大的模式:可以直接将数据获取操作与需要使用这些数据的路由关联起来,这样一来,在很多情况下就无需再进行全局状态管理了。

app/
  orders/
    page.tsx              # 等待getOrders()函数执行结果,然后渲染订单列表
    [id]/
      page.tsx            # 等待getOrder(id)函数执行结果,然后渲染单个订单的详细信息
      loading.tsx         # 在等待数据时显示加载提示界面
      components/
        OrderTimeline.tsx  // 服务器端组件,用于渲染订单时间线数据
        CancelButton.tsx  // 使用“客户端模式”编写,需要添加点击处理逻辑

每个页面都会根据自身需求获取相应的数据。当使用Promise.all或并行路由机制时,嵌套的布局和页面可以同时进行数据请求。loading.tsx这个文件可以让你在不需要手动编写任何标签的情况下,轻松实现加载提示效果。

何时使用数据获取层而非直接查询

随着应用程序规模的增长,你需要一种统一的数据访问方式。以下是一个实用的设计模式:

// packages/database/src/queries/orders.ts
// 该函数在服务器端执行,任何服务器组件都可以导入并使用它

import { db } from '../client';

export async function getOrdersByUser(userId: string) {
  return db.order.findMany({
    where: { userId },
    include: { items: true },
    orderBy: { createdAt: 'desc' },
  });
}
// packages/database/src/index.ts
export { getOrdersByUser } from './queries/orders';
export { getProductById } from './queries/products';
// ...

你的服务器组件应该从@my-platform/database包中导入所需的函数。而客户端组件则永远不需要直接使用这个包:如果它们需要修改数据,就会调用API路由或服务器端动作。

用于数据修改的服务器端动作

数据获取是通过服务器组件来完成的,但数据的修改操作需要单独的处理机制。服务器端动作(使用'use server'语法)允许你定义一些服务器端的函数,客户端组件可以直接调用这些函数,从而无需编写繁琐的API路由代码。

// app/orders/[id]/actions.ts
'use server';

import { db } from '@my-platform/database';
import { revalidatePath } from 'next/cache';

export async function cancelOrder(orderId: string) {
  await db.order.update({
    where: { id: orderId },
    data: { status: 'cancelled', cancelledAt: new Date() },
  });

  revalidatePath `/orders/${orderId}`);
}
// app/orders/[id]/components/CancelButton.tsx
'use client';

import { cancelOrder } from '../actions';
import { useTransition } from 'react';

export function CancelButton({ orderId }: { orderId: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    
  );
}

相关的架构决策如下:

  • 对于那些与特定路由关联的修改操作,应使用“服务器动作”来实现(例如取消订单、更新用户信息)。

  • 而对于那些会被外部客户端使用的修改操作,则应通过API路由来处理(比如Webhook、移动应用或第三方集成系统)。

“服务器动作”能够将修改逻辑紧密地与触发该操作的UI界面关联起来;而API路由则为外部客户端提供了稳定的接口规范。

这样,数据流的架构就清晰明了了:服务器组件负责处理数据的读取操作,“服务器动作”负责数据的写入操作,而客户端组件则充当连接这两者的交互界面。

第五层:分层代码库的测试策略

“测试金字塔”这个概念在理论上听起来很合理,但在实际应用中往往会遇到问题——通常是因为代码库没有明确的边界可供测试。当所有组件相互交织在一起时,每项测试都很容易变成集成测试。

但你所构建的分层架构改变了这一状况:每一层都有明确的职责范围,因此你可以在适当的抽象层次上对每一层进行测试。

在合适的粒度上测试每一层

分层架构与“测试金字塔”的理念非常契合:

层次 测试类型 使用的工具
packages/文件夹中的代码(实用工具、数据库查询等) 单元测试 Vitest
features/文件夹中的代码(钩子函数、库模块、组件等) 单元测试 + 集成测试 Vitest + React Testing Library
应用程序的路由页面(属于服务器组件) 集成测试 Vitest + 自定义渲染逻辑
关键的用户交互流程(如结账、认证等) 端到端测试 Playwright

测试的目标是:对共享的代码包进行全面测试,对各个功能模块进行彻底验证,检查页面之间的集成是否正确,而只有对于那些最重要的交互流程,才需要使用端到端测试。

并不是所有的功能都需要进行端到端测试,如果将端到端测试作为默认的测试策略,那将会是团队可能会犯下的最严重的错误之一。

对共享代码包进行单元测试

packages/文件夹中的代码是最容易进行测试的。这些代码都是纯TypeScript编写的,且没有与任何框架发生耦合关系。可以使用Vitest来进行测试:


// packages/utils/src/dates.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatRelativeDate } from './dates';

describe('formatRelativeDate', () => {
  beforeEach(() => {
    // 为了避免在午夜附近出现测试结果不稳定的情况,需要设置虚拟时间
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('当输入的是当天日期时,该函数应返回"today"`', () => {
    expect(formatRelativeDate(new Date()).toBe('today');
  });

  it('当输入的是前一天日期时,该函数应返回"yesterday"'', () => {
    const yesterday = new Date('2026-03-14T15:00:00Z');
    expect(formatRelativeDate(yesterday)).toBe('yesterday');
  });
});

请将测试文件与源代码文件放在同一个目录中。例如,如果有一个`dates.ts`文件,那么应该有一个与之对应的`dates.test.ts`文件。不要使用单独的`__tests__`文件夹,因为这些文件夹其实是那些代码结构较为混乱的项目遗留下来的习惯。

测试功能模块

功能模块中包含了大部分业务逻辑,因此它们需要接受最多的测试。关键原则是:只需测试功能模块的公共API,而无需测试其内部实现。


// features/auth/hooks/useAuth.test.ts
import { renderHook, act } from '@testing-library/react';
import { useAuth } from '../hooks/useAuth';
import { createWrapper } from '@/test/utils'; // 用于包装测试代码的函数

describe('useAuth', () => {
  it('当会话存在时,会返回已认证的状态', async () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper({ session: mockSession }),
    });

    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user.email).toBe(mockSession.user.email);
  });

  it('当会话为空时,会重定向到登录页面', async () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper({ session: null }),
    });

    expect(result.current.isAuthenticated).toBe(false);
  });
});

请注意,这些测试代码是直接从相应的功能模块中导入相关函数的,而不是从该模块的`index.ts`文件中导入的。功能模块中的公开API才是被测试的对象;而那些内部使用的钩子函数或工具函数则会在单元级别进行测试。这种区分是有意为之的。

测试服务器组件

服务器组件实际上是返回JSX代码的异步函数。目前,直接对这些组件进行测试仍然是一个正在发展中的技术领域。React的测试工具并不支持对异步组件进行原生处理,因此如果直接调用`await DashboardPage()`然后再将结果传递给`render()`方法,很可能会遇到一些问题(比如上下文丢失、`act()`方法会报错,或者根据具体的配置环境导致测试失败)。

目前最可靠的方法是分别对各个层次进行测试:首先模拟数据层,确认相关数据被正确地调用;然后使用静态属性来测试展示层组件。


// app/dashboard/components/MetricsDashboard.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MetricsDashboard } from './Metrics Dashboard';

describe('MetricsDashboard', () => {
  it('能够根据提供的数据渲染收入指标', () => {
    render(
      
    );

    expect(screen.getByText('£84,200')).toBeInTheDocument();
  });
});

// features/analytics/lib/queries.test.ts
import { describe, it, expect } from 'vitest';
import { getMetrics } from './queries';

describe('getMetrics', () => {
  it('能够正确返回收入数据及趋势信息', async () => {
    const metrics = await getMetrics();

    expect(metrics.revenue).toBeGreaterThan(0);
    expect(Array.isArray_metrics.trend)).toBe(true);
  });
});

关键要点是:应该在数据层边界进行测试,而不是在数据库层或网络层进行测试。数据查询相关的测试位于packages/database目录中;用于展示数据的组件也有针对静态属性的专门测试。服务器组件负责将这些测试环节连接起来,而这种连接关系的正确性则通过端到端测试来验证,因为端到端测试更适合用来检测跨异步边界出现的集成问题。

使用Playwright进行端到端测试

只有那些涉及多个层且出现故障后果严重的流程才适合使用Playwright来进行测试,例如认证、结账流程以及会引发副作用的表单提交操作。对于视觉回归测试或处理静态内容的场景,则不适合使用Playwright,因为这样做既耗时又效率低下。

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test('用户能够登录并进入控制面板', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name="email"]', 'test@example.com');
  await page.fill('[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  await expect(page)._haveURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' }).toBeVisible();
});

端到端测试文件应放在单体仓库根目录下的e2e/文件夹中。这些测试文件涉及整个项目中的所有应用,因此不应该被放置在任何单个应用的目录中。

在单体仓库中配置Vitest测试

每个包和应用程序都有自己的vitest.config.ts文件,但它们可以通过共享包来使用相同的基配置文件:

// packages/config/vitest.base.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
    },
  },
});
// apps/web/vitest.config.ts
import { mergeConfig } from 'vitest/config';
import base from '@my-platform/config/vitest.base';

export default mergeConfig(base, {
  test: {
    include: ['src/**/*.test.{ts,tsx}', 'app/**/*.test.{ts,tsx}'],
  },
});

这样的配置能够确保所有应用和包都使用统一的测试设置,从而避免重复劳动。

第6层:使用Turborepo实现持续集成/持续部署

如果一个单体仓库没有配备智能的持续集成管道,那么它仅仅就是一个大型代码库而已。而Turborepo的真正价值在于持续集成环节——通过缓存机制和智能的任务调度功能,它能够显著缩短构建和测试所需的时间。

核心理念:仅运行那些发生了变化的部分

传统的持续集成管道会在每次提交代码时执行所有测试任务。在单体仓库中,这意味着即使你只是修改了apps/web中的某个辅助功能,apps/admin的测试也会被自动执行。而Turborepo凭借其对依赖关系的精准理解,能够有效避免这种情况的发生。

当你运行 `turbo test` 时,Turborepo会执行以下操作:

  1. 根据你的 `package.json` 文件生成依赖关系图。

  2. 检查哪些包发生了变化(与上次缓存的状态进行对比)。

  3. 仅对发生变化的包及其依赖项运行测试。

  4. 将测试结果缓存起来。如果没有发生变化,系统会立即从缓存中恢复之前的结果。

如果 `packages/ui` 发生变化,那么 `packages/ui`、`apps/web` 和 `apps/admin` 的测试也会被触发(因为这些模块都依赖于 `packages/ui`)。而如果只有 `apps/web` 发生变化,那么只有 `apps/web` 的测试会被执行。

远程缓存

如果没有远程缓存,Turborepo的本地缓存对持续集成流程并无帮助——每次运行都会从头开始。而有了远程缓存,构建和测试产生的结果会被存储在云端,并在所有的持续集成工具以及开发者的机器上共享。

# 使用 Turborepo 的远程缓存进行登录(Vercel)
npx turbo login
npx turbo link

如果你希望将结果保存在自己的基础设施上,也可以使用自托管的缓存服务器。一旦配置完成,对于仅修改了 `apps/web` 分支的持续集成任务,执行时间可能会从 8 分钟缩短到 45 秒,因为所有的 `packages/*` 相关操作都会从缓存中获取所需数据。

适用于生产环境的 GitHub Actions 流程

以下是一个完整的流程示例:该流程利用了 Turborepo 的缓存机制,仅执行必要的任务,并将代码检查、测试和构建操作分解为多个并行执行的步骤:

# .github/workflows/ci.yml
name: 持续集成流程

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_team: ${{ secrets.TURBO_TEAM }}

jobs:
  lint:
    name: 代码检查
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo lint --filter="...[origin/main]"

  test:
    name: 测试
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo test --filter="...[origin/main]"

  build:
    name: 构建
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo build --filter="...[origin/main]"

  e2e:
    name: 后端测试
    runs-on: ubuntu-latest
    needs: [build]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx playwright install --with-deps

      - name: 构建应用程序(如果未发生变化,会从 Turborepo 缓存中恢复数据)
        run: npx turbo build --filter="apps/web"

      - name: 运行后端测试
        run: npx turbo e2e

E2E工作模式假定Playwright的`webServer`配置能够自动启动应用程序。请在您的`playwright.config.ts`文件中配置此项:

// playwright.config.ts
export default defineConfig({
  webServer: {
    command: 'npm run start --prefix apps/web',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});

通过这种方式,Playwright会在测试运行之前启动生产服务器,并在测试结束后将其关闭——因此在持续集成环境中无需进行任何手动服务器管理操作。

`–filter=”…[origin/main]”`这个参数是关键所在。它告诉Turborepo仅针对自`main`分支以来发生变化的包以及所有依赖于这些变更包的其他包来执行相应的任务。这是整个流程中效果最为显著的优化措施。

过滤策略

Turborepo的`–filter`参数非常灵活,了解其使用方法是非常有必要的:

# 仅针对那些相对于`main`分支发生变化的包来执行任务
turbo test --filter="...[origin/main]"

# 为某个特定应用程序及其所有依赖项执行任务
turbo build --filter="apps/web..."

# 除了某个特定应用程序之外,为其他所有项目执行任务
turbo test --filter="!apps/admin"

# 为所有应用程序(而非单独的包)执行任务
turbo build --filter="./apps/*"

对于大多数持续集成流程而言,在功能分支上使用`–filter=”…[origin/main]”`,而在`main`分支上进行`turbo run test build`操作(不使用任何过滤条件),这种配置方式是较为合适的。这样既能快速获得对代码变更的反馈,又能确保`main`分支上的所有代码仍然能够正常运行。

带有应用程序级过滤功能的部署流程

当需要将应用程序部署到Vercel、Netlify或任何支持按应用程序进行部署的平台时,Turborepo可以帮助您判断哪些应用程序确实发生了变化,从而跳过那些未发生变化的应用程序的部署过程:

# .github/workflows/deploy.yml
- name: 检查Web应用程序是否发生了变化
  id: check-web
  run: |
    CHANGED=$(npx turbo run build --filter="apps/web...[origin/main]" --dry=json | jq '.packages | length')
    echo "changed=\(CHANGED" >> \)GITHUB_OUTPUT

- name: 部署Web应用程序
  if: steps.check-web.outputs_changed != '0'
  run: vercel deploy --prod
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

这种配置方式可以确保:当只有营销网站发生了变化时,管理员应用程序不会被触发进行部署,从而减少部署所需的时间和成本,同时也能降低任何部署失败所带来的影响范围。

环境变量管理

在单仓库持续集成环境中,环境变量的管理是一个比较棘手的问题:每个应用程序都需要自己独立的配置信息,但有些环境变量却是需要在多个应用程序之间共享的。

一种规范的配置方式如下:

# .env文件(位于仓库根目录下,在本地开发环境中所有应用程序都会共享这些配置)
DATABASE_URL=...
REDIS_URL=...

# apps/web/.env.local文件(仅针对web应用程序的特定配置)
NEXT_PUBLIC_APP_URL=https://app.example.com
STRIPE_KEY=...

# apps/admin/.env.local文件(仅针对admin应用程序的特定配置)
NEXT_PUBLIC_APP_URL=https://admin.example.com
ADMIN_SECRET=...

在持续集成环境中,应将组织级别的共享密钥存储在GitHub的秘密管理功能中,而特定于应用的密钥则应作为仓库级别的密钥保存,并限定在相应的环境范围内使用。

切勿将密钥存储在turbo.json文件或任何已提交的代码文件中。相反,应在管道步骤中使用env变量,并利用Turborepo中的globalEnv字段来指定哪些环境变量在发生变化时需要强制清除缓存:

// turbo.json
{
  "globalEnv": ["NODE_ENV", "DATABASE_URL"],
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_APP_URL", "STRIPE_KEY"],
      "dependsOn": ["^build"],
      "outputs": [".next/**"]
    }
  }
}

这样设置后,Turborepo就会知道:如果DATABASE_URL发生变化,就需要清除所有任务的缓存;而如果NEXT_PUBLIC_APP_URL发生变化,那么只需要清除build任务对应的缓存即可。如果不这样做,就有可能导致Turborepo使用与当前环境不同的配置来重新生成编译结果,从而引发一些难以发现的错误。

整体架构设计

下面是整个系统的完整架构图:

my-platform/
  apps/
    web/
      app/
        (marketing)/
          layout.tsx
          page.tsx
          about/page.tsx
        (app)/
          layout.tsx            # 需经过身份验证才能访问的界面层
          dashboard/
            page.tsx            # 服务器端组件,用于获取数据
            loading.tsx
            components/
              MetricsDashboard.tsx
              ChartSection.tsx  # 使用客户端逻辑
          orders/
            page.tsx
            [id]/
              page.tsx
              components/
                OrderTimeline.tsx
                CancelButton.tsx  # 使用客户端逻辑
      src/
        features/
          auth/
            components/
            hooks/
            lib/
            index.ts
          billing/
            ...
        shared/
          components/
          hooks/
          lib/
    admin/
      app/
        ...                     # 同样的层次结构
      src/
        features/
          ...
  packages/
    ui/                         # 公共的UI组件库
    auth/                       # 共用的认证逻辑模块
    database/                   # Prisma数据库及相关查询逻辑
    config/                     # ESLint配置、TypeScript配置等
    utils/                      # 通用辅助函数
  turbo.json
  package.json

请注意,'use client'这一标记仅出现在那些需要与用户交互的组件中:例如ChartSection.tsx需要使用useState来管理状态,而CancelButton.tsx则需要处理点击事件并使用useTransition来实现动画效果。它们上面的各个组件(如MetricsDashboard.tsxOrderTimeline.tsx等)都运行在服务器端,负责获取数据并生成页面布局,而不会向浏览器发送任何JavaScript代码。

整个系统的层次结构非常清晰明了:

  1. Turborepo包:最基础的一层。这些包具有通用性,可重复使用,使用时无需了解特定应用的细节。

  2. 共享功能层:涉及多个应用共性的功能模块。这类模块可以调用其他包,但并不了解路由机制的具体实现。

  3. 功能模块:包含特定业务逻辑的代码模块,这些逻辑被封装在相应的文件中。

  4. 应用路由器:负责处理路由规则、布局安排以及各个组件的协同工作。它会调用功能模块和包来完成任务,数据流经服务器组件,而交互逻辑则由客户端组件负责实现。

常见误区及避免方法

“暂时就把这些代码放在 /utils 目录里吧。” 这种做法最终会导致代码混乱。如果无法明确某个工具函数的用途,那么它应该被放入专门的功能文件夹中,而不是被随意放置在通用目录里。

过早地将代码提取为独立包:并非所有代码都适合被提取成共享包。只有在有其他组件需要使用这些代码时,才应该将其提取出来。过早进行抽象化处理会增加维护成本,并导致不必要的耦合现象。

将客户端组件放在结构的最顶层:如果某个路由文件的page.tsx文件中在顶部就使用了'use client'指令,那么你就浪费了服务器组件所提供的很多优势。应该将这种指令放到需要使用它的交互逻辑部分。

循环依赖的包结构:如果packages/auth依赖于packages/database,而packages/database又依赖于packages/auth,就会形成循环依赖。必须保证依赖关系图是单向的:每个包都应该只属于一个明确的抽象层次。

包含所有内容的“桶状文件”:桶状文件应该仅用于暴露应用程序中其他部分需要使用的接口,而不应成为整个文件夹中所有文件的索引。

总结

良好的架构设计并不在于寻找完美的结构,而在于让正确的决策变得容易做出,错误的决策则难以实施。

  • 合理的组件布局有助于快速找到所需的功能模块。

  • 功能模块的设计可以有效防止不相关的业务逻辑之间产生不必要的耦合。

  • Turborepo工具便于代码的共享,同时也能有效避免代码重复编写。

  • 服务器组件的使用可以让你在需要的地方轻松获取数据,同时减少向浏览器发送不必要的JavaScript代码。

这些理念其实并不新鲜。分层架构、职责分离以及代码封装都是早已被广泛认可的编程原则。Next.js和Turborepo则为我们在JavaScript代码中实现这些原则提供了现代化的工具。

最佳的设置时机是在项目开始时;其次就是现在,在下一个功能模块被添加之前,及时调整代码结构,以免后续的工作变得更加复杂。

]]> 从与Chris Griffing在Twitch上共同进行的15,031小时编程直播中获得的经验教训——[播客第214集] http://www.cheeli.com.cn/articles/lessons-from-15031-hours-of-coding-live-on-twitch-with-chris-griffing-podcast-214/ Fri, 03 Apr 2026 20:15:58 +0000 http://www.cheeli.com.cn/?p=21040 Read More]]> 今天,Quincy Larson邀请了Chris Griffing进行访谈。Chris是一名软件工程师,也是Twitch平台上非常活跃的直播编程者。他曾在滑雪场从事各种零工工作,这样就能有更多时间在山上滑雪。

28岁时,他自学了PHP编程,并开始为朋友们制作网站。2018年,他在Twitch上开始直播自己的编程过程,这一举动在疫情期间受到了广泛关注,也为他带来了更多的发展机会,使他能够从事开发工作并担任开发者倡导者。

我们今天将讨论以下内容:

  • Chris是如何在28岁时学习编程的,以及他在成为专业人士之前是如何为朋友们开发项目的

  • 学习Go语言如何帮助他成为一名更优秀的Rust程序员,以及为什么你应该成为一名多语言程序员

  • Chris是如何使用大语言模型工具的,但仍然主要通过手动方式编写代码的

  • 对于那些也有兴趣进行直播编程的人来说,一些关于如何公开开发项目的建议

您可以在freeCodeCamp.org的YouTube频道上观看完整的播客节目,或者通过您喜欢的播客应用收听。

与我们讨论内容相关的链接:

社区新闻栏目:

  1. freeCodeCamp刚刚发布了一门综合课程,该课程将指导您如何使用流行的AI辅助开发工具Claude Code。您将学习到关于代码管理、智能代理循环、沙箱环境等关键概念。完成这门课程后,您将能够创建一系列智能代理来帮助您修复错误并开发新功能。(12小时的YouTube课程):https://www.freecodecamp.org/news/claude-code-essentials-exampro/

  2. 我们还发布了一门关于Hugging Face工具生态系统的课程。您将学习如何将模型、数据集和部署工具整合到一个统一的构建流程中。(7小时的YouTube课程):https://www.freecodecamp.org/news/deploying-ai-models-with-hugging-face/

  3. 学习如何保护您的Kubernetes集群。这个深入的教程首先分析了特斯拉、Shopify和Capital One等大公司所遭遇的安全漏洞,然后介绍了如何通过加强系统设置来预防这些攻击。(1小时的阅读材料):https://www.freecodecamp.org/news/how-to-secure-a-kubernetes-cluster-handbook/

  4. 请告诉您的西班牙语朋友:freeCodeCamp刚刚发布了一门新的西班牙语SQL及关系数据库课程。这门课程涵盖了表格、外键、查询语句以及数据操作等内容。(4小时的YouTube课程):https://www.freecodecamp.org/news/learn-sql-course-for-beginners-in-spanish/

  5. 本周推荐的歌曲是Genesis旗下项目Mike + the Mechanics在1988年发行的歌曲《Nobody’s Perfect》。如果您喜欢合成器音乐和吉他独奏,一定会喜欢这首歌。Paul Young的嗓音非常出色,而且这首歌所传达的信息也很有启发性。视频的风格完全符合80年代的特点:https://www.youtube.com/watch?v=L7mQ26YCsho

]]>
如何使用JavaScript逐步构建条形码生成器 http://www.cheeli.com.cn/articles/how-to-build-a-barcode-generator-using-javascript-step-by-step/ Fri, 03 Apr 2026 20:15:58 +0000 http://www.cheeli.com.cn/?p=21038 Read More]]> 如果你曾经开发过库存系统、账单管理界面,或者甚至是某些内部使用的小型工具,那么很可能在某个时候你需要生成条形码。

大多数开发者要么依赖外部工具,要么认为生成条形码需要后端处理。而恰恰是这种处理方式往往会导致程序运行速度变慢、结构变得复杂,同时也更难维护。

但是现代浏览器已经具备了足够强大的能力,可以完全在客户端自行完成条形码的生成工作。

在这个教程中,你将构建一个完全在浏览器中运行的条形码生成工具。这个工具不会向任何地方上传数据,也不需要任何服务器端的逻辑处理——所有操作都会在客户端瞬间完成。

在学习的过程中,你还会了解到条形码的各种格式、如何正确验证输入数据,以及如何创建响应迅速、实用便捷的实时预览功能。

目录

  1. 条形码生成原理

  2. 项目设置

  3. 我们使用了哪些库?

  4. 构建HTML结构

  5. 添加用于生成条形码的JavaScript代码

  6. 条形码的生成过程

  7. 可以生成的条形码类型

  8. 添加实时预览功能

  9. 如何正确验证输入数据

  10. 如何下载生成的条形码

  11. 实际使用中的注意事项

  12. 常见错误及避免方法

  13. 演示:条形码生成工具的工作原理

  14. 总结

条形码生成原理

条形码实际上是一种数据的可视化编码方式。它不是直接显示文本,而是通过一系列线条和空格来表示这些数据。

不同的条形码格式采用不同的编码规则:有些只支持数字输入,而有些则允许输入完整的文本。当你在浏览器中生成条形码时,其实就是在将用户输入的数据转换成一种结构化的可视化图像。

关键在于,我们并不需要手动绘制这些线条——相关的库会负责对数据进行编码,并将其渲染成SVG格式的图像,而浏览器可以立即显示这些图像。

项目设置

<我们会故意让这个项目保持简单,这样人们就能把注意力集中在理解它的运作原理上。>

你所需要的只是一个基本的HTML文件、一个小的JavaScript文件,以及一个条形码生成库。这个过程不需要后端支持,也不会有任何数据被存储或上传。

这样的设计使得这个工具运行速度快、使用私密性强,而且很容易集成到其他项目中。

我们使用了哪些库?

在这个项目中,我们使用了JsBarcode这个库。

这是一个轻量级的JavaScript库,它可以使用SVG在浏览器中直接生成条形码。该库支持多种格式,并且不需要任何外部依赖。

你可以通过CDN来引入这个库:

<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.5/dist/JsBarcode.all.min.js"></script>

创建HTML结构

这个界面的设计简单实用。它包含一个输入字段,用户可以在其中输入数据;还有一个下拉菜单,用于选择条形码的格式;此外还有一个预览区域,用于显示生成的条形码。

<input type="text" id="text" placeholder="请输入文本或数字">

<select id="format">
  <option value="CODE128">>Code128EAN13
</select>

<button onclick="generateBarcode()">>生成条形码

这样的结构已经足以完成数据的输入、输出显示,以及通过JavaScript将所有这些功能连接起来。

添加用于生成条形码的JavaScript代码

现在我们要将用户的输入与条形码的生成过程连接起来。

function generateBarcode() {
  const text = document.getElementById("text").value;
  const format = document.getElementById("format").value;

  if (!text) {
    alert("请输入内容");
    return;
  }

  Js Barcode("#barcode", text, {
    format: format,
    width: 2,
    height: 100,
    displayValue: true
  });
}

这个函数会读取用户输入的内容,检查其是否存在,然后使用所选择的格式生成条形码。

条形码是如何生成的

当你调用JsBarcode函数时,这个库会在后台处理所有相关的工作。

它会将用户输入的内容编码成符合条形码标准的格式,然后将其转换成由线条组成的图像,并以SVG元素的形式显示出来。由于SVG是基于矢量的格式,因此即使调整大小,条形码的清晰度也不会受到影响。

所有这些操作都在浏览器中瞬间完成,因此使用起来会感觉非常快捷。

你可以生成的条形码类型

不同的行业会使用不同类型的条形码,了解这些差异有助于你开发出更加实用的工具。

  1. Code128是最灵活的条形码格式。它支持字母、数字和特殊字符,因此非常适合用于各种通用场景。

  2. EAN-13常用于零售产品。这种条形码只能使用13位数字,因此需要对输入数据进行严格的验证。

  3. UPC与EAN类似,也广泛用于结算系统,尤其是在美国。它同样要求输入的是固定长度的数字。

  4. Code39相对简单一些,它支持大写字母和数字,但相比Code128,它的编码效率较低。

  5. ITF-14主要应用于物流和包装领域。这种条形码专为数字数据设计,在运输行业中非常常见。

在大多数情况下,除非有特殊需求,否则从使用Code128格式开始才是最安全的选择。

添加实时预览功能

对于这类工具来说,最重要的改进之一就是提供实时反馈功能。

你不必让用户每次输入内容时都点击按钮,而是可以在他们输入的同时自动生成条形码。

document.getElementById("text").addEventListener("input", generateBarcode);
document.getElementById("format").addEventListener("change", generate Barcode);

这个小小的改动会让工具的使用体验变得更好。每当用户输入内容或更改格式时,条形码都会自动更新——这种交互方式与那些专业级的生产工具中所使用的机制完全相同。

如何正确验证输入内容

在很多简单的工具中,输入内容的验证环节往往会出现问题。

由于不同的条形码格式有着不同的规则,如果不能正确验证输入内容,条形码可能会无法正常生成,或者产生错误的结果。

下面是一个简单的例子:

function isValidInput(text, format) {
  if (format === "EAN13") {
    return /^\d{13}$/.test(text);
  }

  if (format === "UPC") {
    return /^\d{12}$/.test(text);
  }

  return text.length > 0;
}

你可以在生成条形码的代码中加入这段验证逻辑:

if (!isValidInput(text, format)) {
  alert("所选格式的输入内容无效");
  return;
}

这样就能确保用户能够立即得到反馈,而不会感到困惑。

如何下载条形码文件

一旦条形码生成完成,你就可以允许用户将其下载下来。

function downloadBarcode() {
  const svg = document.getElementById("barcode");
  const serializer = new XMLSerializer();
  const source = serializer.serializeToString/svg);

  const blob = new Blob([source], { type: "image/svg+xml" });
  const url = URL.createObjectURL(blob);

  const link = document.createElement("a");
  link.href = url;
  link.download = "barcode.svg";
  link.click();
}

这样就可以将SVG格式的条形码文件直接从浏览器中下载下来。

实际使用中的重要注意事项

在正式环境中开发这类工具时,细节问题往往非常重要。

当输入的数据量较大时,条形码的可视性可能会受到影响,因此测试条形码的显示效果是非常必要的。选择合适的条形码格式也很关键——如果你需要灵活性,还是严格的规范标准,这些因素都会产生影响。

另一个重要的方面是渲染质量。使用SVG格式而非位图格式,可以确保条形码在打印后依然保持清晰的视觉效果。

应避免的常见错误

一个常见的错误就是忽略输入内容的验证环节。这样会导致生成的条形码出现错误或无法正常读取,尤其是在使用像EAN或UPC这样的严格格式时。

另一个常见的错误就是过度依赖基于按钮的操作方式。实时更新能够带来更好的用户体验。

最后,开发人员有时会忘记正确引入相关库文件,从而导致程序在无声无息中出现故障。因此,请务必确认你的CDN已经成功加载。

演示:条形码生成器的使用原理

为了更好地理解整个工具的工作流程,下面我们来快速了解一下它在浏览器中的运行方式。

条形码生成器界面,提供Code128、EAN-13、UPC等条形码格式选择选项,同时设有用于输入条形码数据的字段

步骤1:选择条形码格式

首先需要选择条形码的格式。在大多数情况下,Code128是一个不错的选择,因为它既支持文本信息也支持数字。

步骤2:输入数据

接下来,你需要输入想要编码的信息。根据所选择的格式,这些信息可以是一条产品编号、一个URL地址,或者任何文本内容。

条形码自定义面板,提供调整条带颜色、背景颜色、宽度、高度以及显示设置的功能

步骤3:自定义设计

你可以调整条带的宽度、高度以及颜色等参数。这些设置能够影响条形码的外观,从而影响其在不同使用场景下的可读性。

根据用户输入在浏览器中显示的生成后的条形码预览图

步骤4:生成并预览结果

当你输入数据或调整设置时,条形码会立即更新。这种实时预览功能使得你能够方便地进行尝试,并立即看到结果。

提供PNG、JPG和SVG格式的生成条形码下载选项

步骤5:下载条形码文件

当你对生成的结果满意后,可以将其下载为PNG、JPG或SVG格式的文件。

整个操作过程都是在浏览器中完成的,无需将任何数据上传到服务器上。

总结

通过本教程,你已经使用JavaScript在浏览器中开发出了一个条形码生成工具。

更重要的是,你们学会了如何去思考那些完全在客户端运行的工具的开发方法。这种开发方式能够降低系统的复杂性、提升性能,从而为用户带来更加快速的体验。一旦你理解了这种模式,就可以将其应用到许多其他工具中,比如二维码生成器、图像转换工具以及文件处理程序。
而恰恰在这里,事情才开始变得有趣起来。

]]>
面向开发者的AI工具:OpenClaw、GitHub Copilot、Claude Code、CodeRabbit以及Gemini CLI http://www.cheeli.com.cn/articles/ai-tools-for-developers-openclaw-github-copilot-claude-code-coderabbit-gemini-cli/ Thu, 02 Apr 2026 20:18:39 +0000 http://www.cheeli.com.cn/?p=21032 Read More]]> 使用人工智能工具是成为一名软件开发人员的重要环节。

我们刚刚在freeCodeCamp.org的YouTube频道上发布了一门课程,这门课程会教你如何利用人工智能工具来提升自己的开发效率。这门课程就是由我创建的!

在这门课程中,你将掌握使用GitHub Copilot、Anthropic的Claude Code以及Gemini CLI等顶级工具进行AI协作编程以及自动化终端操作的方法。课程还会介绍如何使用OpenClaw实现开源自动化功能,教你如何为自己的开发环境搭建一个高度可定制的、本地运行的AI辅助工具。最后,你还将学习如何通过集成CodeRabbit来自动分析拉取请求,从而保持代码的高质量并优化团队的工作流程。

请在freeCodeCamp.org的YouTube频道上观看这门完整的课程(时长为1.5小时)。

]]>
“不良网站俱乐部”正在基于freeCodeCamp开展一场免费的响应式网页设计培训营活动。 http://www.cheeli.com.cn/articles/the-bad-website-club-is-running-a-free-responsive-web-design-bootcamp-based-on-freecodecamp/ Thu, 02 Apr 2026 20:18:39 +0000 http://www.cheeli.com.cn/?p=21030 Read More]]> 大家好!

我们(Jess、Carmen和Eda)非常高兴地宣布,下一期免费在线训练营即将开始。我们会为学习者们提供支持,帮助他们顺利完成freeCodeCamp的响应式网页设计课程。这次训练营将于4月24日星期五启动,持续10周,直到6月3日星期五结束。

以下是本次训练营的具体安排:

  • 直播环节:我们将在周一至周五的UTC时间15:00进行直播(你可以在这里查看你所在时区),在我们的YouTube频道Jess的Twitch频道以及Carmen的Twitch频道上一起学习课程内容。大家也可以在直播期间提出任何问题!如果无法观看实时直播,这些录像也会被保存下来,供日后观看。

  • 嘉宾讲座:我们还会邀请软件工程及相关领域的专业人士来举办专题讲座,分享他们的专业经验。

  • 社区互动:为了帮助大家互相学习、共同进步,我们在freeCodeCamp的Discord服务器上专门建立了一个交流频道(你可以点击这里加入:https://discord.gg/KVUmVXA),这样你就可以与其他正在学习freeCodeCamp课程的朋友们建立联系。我们还会在我们的网站上分享学习笔记。

  • 日程安排:我们会制定一个共同学习的日程表,与一群热情且友好的学习者们一起学习。

  • 新闻通讯:无需注册即可接收我们的每周邮件通知,如果你愿意的话,也可以订阅我们的新闻通讯

训练营的具体运作方式是什么?

Bad Website Club的训练营已经运行了一段时间,所以如果你之前参加过我们的活动,这次的学习体验可能会有一些不同之处。

随着freeCodeCamp课程内容的不断扩展,我们也相应地调整了学习模式,以更好地适应这些新的内容。

我们尝试采用了“翻转课堂”模式:学习者在上课前会先进行预习和独立练习,然后在直播环节中与大家一起讨论、互相帮助学习。

此外,学习者们还可以共同参与课程笔记的编写,我们会在单元复习时对这些笔记进行讲解。在直播过程中,我们也会针对一些学习步骤进行详细分析,并留出时间回答大家的疑问。

实验课和认证项目的部分内容需要独立完成,但我们会通过直播来探讨如何一起完成这些任务,并在直播中解答相关问题。

<我们会加快速度进行学习,但并没有固定的截止日期!如果你需要更多时间来完成学习任务,你的学习进度会保存在你的freeCodeCamp账户中。此外,在训练营结束后,这些视频以及我们的Discord社区仍然可以供你继续使用。>

<注意,由于我们的学习者分布在全球各地,且人数众多,因此这个训练营并不提供就业安置支持或一对一的导师辅导服务。但我们会帮助你们掌握那些能够让你们独立寻找发展机会所需的技能。

关于我们

自2020年以来,“Bad Website Club”一直致力于开展免费的在线开发者教育项目。这个项目由一个小型志愿者团队运营:Jessica RoseCarmen Huidobro以及Eda Eren》——同时也要感谢那位为所有视觉设计工作付出努力的Kiri!我们更注重学习与实践,而非追求完美;我们认为,只有让每个人都能参与到互联网的发展中,它才能变得更好。

另外,这个项目确实是完全免费的,没有任何付费方式。

如果你想加入我们,可以下载或订阅完整的课程安排表(对于Google Calendar来说,请使用这些订阅指南来设置iCal提醒),同时也可以关注我们的YouTube频道,以及Jess的Twitch频道Carmen的Twitch频道》。此外,你还可以注册我们的每周通讯,获取课程笔记、项目展示信息、额外学习资源等等。

期待很快能与你们见面!

]]>
如何使用 TanStack Start、Elysia 以及 Neon 来构建一个完整的 SaaS 应用程序 http://www.cheeli.com.cn/articles/how-to-build-a-full-stack-saas-app-with-tanstack-start-elysia-and-neon/ Thu, 02 Apr 2026 20:18:39 +0000 http://www.cheeli.com.cn/?p=21028 Read More]]> 大多数关于全栈React的教程都只停留在“Hello World”这个阶段。它们会教你如何渲染一个组件,或许还会演示如何获取一些数据,然后就结束了。

但当你真正开始构建一个真正的SaaS应用程序时,你会立刻遇到一系列亟待解决的问题:应该如何设计数据库结构?认证功能应该放在哪里实现?如何确保API调用具有类型安全性?在处理支付逻辑时又该如何避免丢失Webhook信号呢?

这本手册能够解答所有这些问题。你将使用TanStack Start、Elysia、Drizzle ORM、Neon PostgreSQL、Better Auth、Stripe以及Inngest这些工具,从零开始构建一个可投入生产的SaaS应用程序。

最终,你会得到一个具备认证功能、API类型安全性、数据库迁移机制、支付处理能力以及后台作业功能的完整应用程序。

在选择这套技术栈之前,我曾经使用Next.js、Express和Prisma构建过多个生产环境下的应用程序。而TanStack Start与Elysia的结合,再加上Eden Treaty的支持,能够为你提供端到端的类型安全性保障——从数据库架构到React组件,整个开发过程完全不需要生成任何代码。

如果你修改了数据库中的某列数据,TypeScript会自动提示你所有需要更新的地方。这种反馈机制会彻底改变你的软件开发方式。

以下是你将学到的内容:

  • 如何使用Vite和基于文件的路由系统来设置TanStack Start项目

  • 如何使用Drizzle ORM和Neon来配置PostgreSQL数据库

  • 如何将Elysia集成到你的Web应用程序中,从而构建出具有类型安全性的API

  • 如何利用Eden Treaty将前端与后端API连接起来

  • 如何使用Better Auth添加GitHub OAuth认证功能

  • 如何运用可复用的四层架构模式来开发完整的功能模块

  • 如何利用Stripe的Webhook功能处理支付业务

  • 如何使用Inngest来运行可靠的后台作业任务

  • 如何利用Neon将整个应用程序部署到Vercel平台上

为什么选择TanStack Start而不是Next.js?

你可能会想:为什么不直接使用Next.js呢?它毕竟是全栈React框架的首选,而且理由也很充分。Next.js率先实现了服务器端渲染技术,确立了许多影响整个React生态系统的开发规范,同时也拥有最大的开发者社区。

但对于这类项目来说,TanStack Start确实具有三个显著的优势。

1. 部署灵活性

TanStack Start编译后的代码是标准的JavaScript,因此可以在任何环境中运行:无论是Node.js、Bun、Deno、Cloudflare Workers、AWS Lambda,还是你自己的服务器。而Next.js则很难在Vercel之外进行自我托管。

如果你在Stack Overflow上搜索“Next.js Azure App Service container”或“Next.js ISR self-hosted”,你会看到许多关于那些只有在生产环境中才会出现的特殊情况的讨论。

2. 更简单的开发思维模型

Next.js已经变得越来越复杂了:App Router、React Server Components、Server Actions、部分预渲染机制、《cache()`函数、《unstable_cache()`函数,再加上各种渲染策略……

TanStack Start采用了全文档的SSR技术,并确保了数据内容的完整加载。因此,不存在服务器端与客户端之间的混淆问题。不过,这种方式的缺点是无法实现RSC所提供的精细流式处理功能,但它的优势在于代码结构更加清晰、可预测性更强。

3. 端到端的类型安全性

结合Elysia与Eden Treaty的技术,TanStack Start能够从数据库层面开始,一直到用户界面,实现编译时的类型推断。因此,无需进行任何代码生成操作,也无需维护额外的模式文件来确保数据一致性。

TanStack Router本身就提供了完全类型的路由功能,它可以自动识别路径参数、搜索参数以及加载器所需的数据类型。

这是一本指导手册,所以内容会相当深入。请抽出几个小时的时间,打开你的编辑器,让我们一起开始实际开发吧。

目录

先决条件

在开始之前,请确保你已经安装了以下工具:

  • Bun(v1.2或更高版本),用于包管理及脚本执行

  • Docker,用于在本地运行PostgreSQL数据库

  • Git,用于版本控制

  • 需要具备React和TypeScript的基本使用知识

此外,你还需要在这些服务上注册免费账户:

  • Neon,用于部署生产环境的PostgreSQL数据库

  • Vercel,用于应用部署

  • GitHub,用于OAuth认证(你需要创建一个 OAuth应用程序)

  • Stripe,用于支付处理功能(测试模式是免费的)

所有这些服务都提供了丰富的免费使用方案。阅读本教程时,您完全不需要支付任何费用。

您还需要能够理解TypeScript代码。本手册假定您已经掌握了泛型、类型推导以及async/await的相关知识。如果您是TypeScript新手,官方手册将是一个很好的入门资源。

如何设置项目

首先创建一个新的TanStack Start项目。TanStack提供了一个命令行工具,该工具能够快速搭建一个包含基于文件的路由系统、Vite框架以及服务器端渲染功能的项目结构。

bunx @tanstack/cli@latest create my-saas
cd my-saas
bun install

命令行工具会询问您一些问题。请选择React作为您的开发框架,其余选项则直接使用默认设置即可。

您将使用Bun作为包管理器和运行时环境。与npm相比,Bun在安装依赖项和执行脚本方面速度要快得多。此外,Bun还原生支持TypeScript的编译,这意味着您可以直接运行`.ts`文件而无需进行任何编译步骤。

如果您更喜欢使用npm或pnpm,这些命令也是类似的,但本教程全程都会使用Bun作为工具。

如何理解项目结构

在开始编写代码之前,我们先来看看这个项目的整体架构。一个关键的设计原则是将所有的库文件放在`src/lib/`目录下。对于各种集成模块(如数据库、认证系统、支付接口等),它们都会被放置在单独的目录中,并通过`index.ts`文件来提供统一的公共API接口。

以下就是您最终会构建出的项目结构:

my-saas/
├── src/
│   ├── components/          # React组件
│   ├── hooks/               # 自定义React钩子
│   ├── lib/
│   │   ├── auth/            # Better Auth认证系统
│   │   ├── db/              # Drizzle ORM数据库框架
│   │   ├── jobs/            # 后台任务处理模块
│   │   └── payments/        # Stripe支付接口集成
│   ├── routes/              # TanStack基于文件的路由系统
│   ├── server/
│   │   ├── api.ts           # Elysia API定义文件
│   │   └── routes/          # API路由模块
│   └── start.ts             | TanStack Start项目入口文件
├── docker-compose.yml       | 本地PostgreSQL数据库配置文件及Neon代理设置
├── drizzle.config.ts        | Drizzle Kit配置文件
├── vite.config.ts           | Vite与TanStack Start配置文件
└── package.json

所有这些组件之间的连接关系如下:

全栈SaaS架构图:TanStack Start负责处理前端逻辑,与Elysia API服务器相连;认证功能通过Better Auth实现,支付接口使用Stripe,后台任务由Inngest处理,而Drizzle ORM则提供了类型安全的数据库访问机制

TanStack Start负责处理前端逻辑。它与同一项目中嵌入的Elysia API服务器进行交互。而Elysia则会连接三个外部服务:用于身份验证的Better Auth、用于支付的Stripe,以及用于处理后台任务的Inngest。在API层之下,Drizzle ORM为Neon PostgreSQL提供了类型安全的数据库访问机制。

你会逐一构建这些组件,首先从数据库开始。

这种架构设计使得所有的集成模块都相互独立。当你需要修改身份验证的实现方式时,只需前往`src/lib/auth/`目录;而如果要调整数据库结构,则去`src/lib/db/`目录进行操作。这样,就不会有任何功能影响到其他部分。

如何配置Vite

TanStack Start是在Vite环境下运行的。你的`vite.config.ts`文件需要添加TanStack Start插件、React插件,以及用于处理`@/`导入别名的路径配置:


// vite.config.ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  server: {
    port: 3000,
  },
  plugins: [
    tsConfigPaths({
      projects: ["./tsconfig.json"],
    }),
    tanstackStart(),
    viteReact(),
  ],
});

`tsConfigPaths`插件会读取你`tsconfig.json`文件中的路径配置,因此你在代码中就可以使用`@/lib/db`代替`../../lib/db`了。

将以下内容添加到你的`tsconfig.json`文件中:


{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

如何安装依赖项

请安装本教程中所需的所有核心依赖项:


# 框架与路由相关依赖
bun add @tanstack/react-router @tanstack/react-start react react-dom

# API层相关依赖
bun add elysia @elysiajs/eden

# 数据库相关依赖
bun add drizzle-orm @neondatabase/serverless ws
bun add -d drizzle-kit

# 身份验证相关依赖
bun add better-auth

# 支付相关依赖
bun add stripe

# 后台任务处理相关依赖
bun add inngest

# 构建工具相关依赖
bun add -d @vitejs/plugin-react vite vite-tsconfig-paths typescript

现在,你已经拥有了一个包含了所有所需依赖项的可用TanStack Start项目。请启动开发服务器,确认一切都能正常运行:


bun run dev

访问`http://localhost:3000`,你应该能看到你的应用程序正在运行。

如何使用Drizzle和Neon配置数据库

任何SaaS应用都需要数据库。在这里,你会使用Drizzle ORM与Neon PostgreSQL配合使用。Drizzle能提供类型安全的数据库查询语句,其语法类似于SQL;而Neon则提供了一个无服务器架构的PostgreSQL数据库,在你不使用它的时候,系统会自动将其资源消耗降为零。

为什么选择Drizzle而不是Prisma?

如果你之前在TypeScript生态系统中使用过ORM,那么很可能是Prisma。Prisma在很多场景下都非常适用,但在这种架构中它存在一个关键缺陷:它需要通过代码生成来生成相应的类型定义。

你需要编写一个`.prisma`文件,然后运行`prisma generate`命令,Prisma会为此生成TypeScript客户端代码。这种生成过程会增加你的开发效率,并且还会产生一些需要保持同步的中间文件。

而Drizzle采用了不同的方式:你的数据库模式和查询语句都是TypeScript编写的,类型信息会在编译时自动推断出来,无需任何额外的生成步骤。

当你向表格中添加一个列时,相关类型会立即得到更新。这种设计与其他技术栈非常兼容——类型信息可以从Drizzle传递到Elysia,再进入Eden Treaty,整个过程没有任何中间环节。

此外,Drizzle生成的SQL语句与传统的SQL语法完全一致。如果你熟悉PostgreSQL,就可以直接理解Drizzle的查询语句;根本不需要学习Prisma特有的查询语言。

如何使用Docker搭建本地PostgreSQL环境

对于本地开发来说,你可以在Docker中运行PostgreSQL,并使用与Neon兼容的代理服务器。这样,你就可以在本地使用与生产环境中相同的Neon无服务器驱动程序。

在项目根目录下创建一个`docker-compose.yml`文件:

# docker-compose.yml
services:
  postgres:
    image: postgres:17
    container_name: my-saas-postgres
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: my_saas
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  neon-proxy:
    image: ghcr.io/timowilhelm/local-neon-http-proxy:main
    container_name: my-saas-neon-proxy
    restart: unless-stopped
    environment:
      - PG_CONNECTION_STRING=postgres://postgres:postgres@postgres:5432/my_saas
    ports:
      - "4444:4444"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:

neon-proxy容器是这个方案中的关键部分。它负责将HTTP请求转换成PostgreSQL能够识别的协议格式,这样你的Neon无服务器驱动程序就可以在本地正常运行了,而无需对代码进行任何修改。

在生产环境中,Neon会在他们的基础设施上完成这种转换工作;而在本地开发时,这个代理服务器就起到了连接HTTP协议的Neon驱动程序与PostgreSQL容器的作用。
PostgreSQL容器的`healthcheck`配置确保了只有当数据库准备好之后,代理服务器才会启动。如果没有这个机制,代理服务器会尝试连接到还在初始化中的数据库,从而导致启动失败。
现在来启动这些容器:
class="language-bash">docker compose up -d

如何定义数据结构

首先创建数据库客户端及相应的数据结构。连接操作可以从src/lib/db/index.ts文件开始进行:


// src/lib/db/index.ts
import { neon, neonConfig } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import ws from "ws";

import * as schema from "./schema";

const isProduction = process.env.NODE_ENV === "production";
const LOCAL_DB_HOST = "db.localtest.me";

let connectionString = process.env.DATABASE_URL;

if (!connectionString) {
  throw new Error("环境变量DATABASE_URL未设置");
}

neonConfig.webSocketConstructor = ws;

if (!isProduction) {
  connectionString = `postgres://postgres:postgres@${LOCAL_DB_HOST}:5432/my_saas`;
  neonConfig.fetchEndpoint = (host) => {
    const [protocol, port] =
      host === LOCAL_DB_HOST ? ["http", 4444] : ["https", 443];
    return `\({protocol}://\){host}:${port}/sql";
  };
  neonConfig.useSecureWebSocket = false;
  neonConfig.wsProxy = (host) =>
    host === LOCAL_DB_HOST ? `\({host}:4444/v2` : `\){host}/v2`;
}

const client = neon(connectionString);
export const db = drizzle({ client, schema });

export * from "./schema";

主机名db.localtest.me实际上对应的是127.0.0.1,这是使用本地Neon代理的标准方式。在生产环境中,Neon驱动程序会直接通过环境变量DATABASE_URL连接到你的Neon数据库。

现在可以在src/lib/db/schema.ts文件中定义数据结构了。对于SaaS应用程序来说,你需要用户信息、会话记录、账户信息(用于OAuth认证),以及表示核心业务实体的表格。以下是一个实际的生产环境数据结构示例:


// src/lib/db/schema.ts
import {
  boolean,
  integer,
  pgEnum,
  pgTable,
  text,
  timestamp,
  varchar,
} from "drizzle-orm/pg-core";

export const purchaseTierEnum = pgEnum("purchase_tier", ["pro"));
export const purchaseStatusEnum = pgEnum("purchase_status", [
  "completed",
  "partially_refunded",
  "refunded",
]);

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  name: text("name"),
  image: text("image"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const sessions = pgTable("sessions", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() =&> users.id, { onDelete: "cascade" }),
  token: text("token").notNull().unique(),
  expiresAt: timestamp("expires_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const accounts = pgTable("accounts", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() =&> users.id, { onDelete: "cascade" }),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  accessTokenExpiresAt: timestamp("access_token_expires_at"),
  refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
  scope: text("scope"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const verifications = pgTable("verifications", {
  id: text("id").primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() =&> crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() =&> users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment(intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

// 为你的应用程序提供类型导出
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Purchase = typeof purchases.$inferSelect;
export type NewPurchase = typeof purchases.$inferInsert;

推送该模式以创建相应的表格:

bun run db:push

关于这个模式,有几点需要注意:

  1. `users`、`sessions`、`accounts`和`verifications`这些表格是Better Auth所必需的。在下一节中,你会配置认证库来使用这些表格。

  2. `purchases`表格是你的核心业务实体。它用于记录Stripe结算过程中的相关信息,并将这些信息与用户关联起来。

  3. 像`User`和`Purchase`这样的类型导出,会根据模式自动为你生成TypeScript类型定义。你无需手动定义类型,这些类型都是从模式定义中得来的。

  4. 在`purchases.id`列上使用了`$defaultFn`,这样在插入数据时就会自动生成UUID。由于Better Auth会自己生成ID,因此认证表格中也使用文本形式的ID。

如何配置Drizzle Kit

在项目根目录下创建`drizzle.config.ts`文件:

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/lib/db/schema.ts",
  out: "./drizzle",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

将以下脚本添加到`package.json`文件中:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:push": "drizzle-kit push",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

现在将你的模式推送到本地数据库中:

bun run db:push

Drizzle Kit会读取你的模式文件,将其与数据库中的数据进行对比,并应用任何必要的更改。在开发环境中,使用`db:push`命令非常快捷方便;而在生产环境中,你应该使用`db:generate`和`db:migrate`命令来生成分版本的SQL迁移脚本。

你还可以打开Drizzle Studio来直观地查看数据库内容:

bun run db:studio

这样会在`https://local.drizzle.studio`这个地址打开一个Web界面,在那里你可以浏览表格、运行查询并检查数据。

如何使用Elysia构建API

在这里,这种技术架构就显得非常有趣了。你不需要单独运行API服务器,而是可以直接将Elysia嵌入到TanStack Start中。这样,你的Web应用和API就会运行在同一个进程中,共享相同的类型定义,并且可以作为一个整体进行部署。

为什么选择Elysia而不是Express?

如果你之前曾经开发过Node.js API,那么很可能使用过Express。Express已经存在了15年,拥有庞大的生态系统。但是Express是在TypeScript、async/await这些技术出现之前设计的,因此它在支持类型安全方面并不完善。

Elysia采取了不同的实现方式。从一开始,它就是为TypeScript设计的。请求体、响应类型以及路径参数都是在编译时被推断出来的。

结合Eden Treaty(你将在下一节中配置它),当你的前端调用API时,就能享受到完整的类型安全性。无需生成任何代码,也无需维护OpenAPI规范文件,只需依靠TypeScript的类型推断机制即可。

Elysia还内置了请求验证功能,这要归功于它的t(TypeBox)模式构建工具:

import { Elysia, t } from "elysia";

new Elysia().post(
  "/users",
  ({ body }) => {
    // body被定义为{ name: string, email: string }类型
    return createUser(body);
  },
  {
    body: t.Object({
      name: t.String(),
      email: t.String(),
    }),
  }
);

该模式在运行时进行验证,而在编译时则为代码提供TypeScript类型信息。同一个定义可以同时满足这两种需求。

如何定义你的API

创建文件src/server/api.ts,所有API路由都会放在这个文件中:

// src/server/api.ts
import { Elysia, t } from "elysia";
import { eq } from "drizzle-orm";

import { auth } from "@/lib/auth";
import { db, purchases, users } from "@lib/db";

export const api = new Elysia({ prefix: "/api" })
  .onRequest(({ request }) => {
    console.log(`[API] \({request.method} \){request.url}`);
  })
  .onError(({ code, error, path }) => {
    console.error(`[API ERROR] \({code} on \){path}:`, error);
  })
  .get("/health", () => ({
    status: "ok",
    timestamp: new Date().toISOString(),
  }))
  .get("/me", async ({ request, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    return { user: session.user };
  })
  .get("/payments/status", async ({ request, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    const purchase = await db
      .select()
      .from(purchases)
      .where(eq(purchases.userId, session.user.id))
      .limit(1);

    return {
      userId: session.user.id,
      purchase: purchase[0] ?? null,
    };
  });

export type Api = typeof api;

最后那一行代码非常重要。export type Api = typeof api这一句代码用于导出你的API的完整类型签名。Eden Treaty会利用这个类型信息在前端生成一个类型完备的客户端代码。

你很快就会了解到这是如何实现的。

请注意,对于需要身份验证的接口来说,其实现方式非常直观:只需调用auth.api.getSession()并传入请求头信息,然后检查会话是否存在;如果不存在,则返回401错误码。这种设计既简单明了,也不需要使用任何装饰器或中间件。

onRequestonError这两个钩子函数可以为每一个请求提供日志记录功能。在生产环境中,你应该将这些日志直接发送到你的监控平台中。

如何在TanStack Start中配置Elysia

TanStack Start采用基于文件的路由机制。若要使用Elysia处理所有API请求,需在src/routes/api.$.ts文件中创建一个通用路由规则:

// src/routes/api.$.ts
import { createFileRoute } from "@tanstack/react-router";

import { api } from "../server/api";

const handler = ({ request }: { request: Request }) => api.fetch(request);

export const Route = createFileRoute("/api/$") {
  server: {
    handlers: {
      GET: handler,
      POST: handler,
      PUT: handler,
      PATCH: handler,
      DELETE: handler,
      OPTIONS: handler,
    },
  },
};

文件名中的$是TanStack Router的通配符语法。该路由规则会匹配任何以/api/开头的路径,而serverhandlers对象会将各种HTTP方法映射到对应的Elysia处理函数上。所有发往/api/*的请求都会被转发给Elysia的fetch方法进行处理。

这一设计的关键在于:Elysia是直接嵌入到TanStack Start中的,因此不存在独立的API服务器。你的Web应用和API共享同一个进程、同一个端口,也采用相同的部署方式。

这样的架构设计可以有效解决CORS问题,简化部署流程,并且还能让你在前端直接导入API相关类型。

你可以通过访问http://localhost:3000/api/health来测试你的API。你应该会看到如下响应:

{ "status": "ok", "timestamp": "2026-03-28T12:00:00.000Z" }

如何使用Eden Treaty实现类型安全的API调用

Eden Treaty是Elysia配套提供的客户端库。它是一个端到端的、类型安全的HTTP客户端,其路由结构会以JavaScript对象的形式被映射出来。你无需手动编写fetch("/api/users")这样的代码并处理响应,只需调用api.api.users.get(),就能享受到自动补全、参数验证以及返回类型推断等功能——所有这些功能都是在编译时根据你的服务器代码生成的,且完全不需要额外编写任何代码。

正是这一特性使得整个技术栈变得格外特别。Eden Treaty会读取你Elysia API中定义的类型信息,从而生成一个类型完整的客户端。每个接口、每个参数、以及每种响应格式都会在编译时被确定下来。

如何配置Treaty客户端

由于Elysia已经嵌入到了你的TanStack Start应用中(属于同一来源域),因此你无需向Treaty客户端传递URL地址。你可以直接从Elysia应用实例中创建客户端用于服务器端开发,而对于浏览器端开发,则可以使用基于URL的客户端。

// src/lib/treaty.ts
import { treaty } from "@elysiajs/eden";

import type { Api } from "@/server/api";

// 用于浏览器端时,连接到相同的来源域
export const api = treaty( 
  typeof window !== "undefined" 
    ? window.location.origin 
    : (process.env.BETTER_AUTH_URL ?? "http://localhost:3000") 
);

现在,你可以在应用程序的任何地方使用api,而且能够享受到完整的类型安全性:

// 调用GET /api/health接口
const { data } = await api.api.health.get();
// data的数据类型为{ status: string, timestamp: string }

// 调用GET /api/me接口(需要认证)
const { data: me, error } = await api.api.me.get();
// data的数据类型为{ user: { id: string, email: string, ... } }
// error的数据类型为{ error: string } | null

请注意,方法链的构成与你的路由结构是完全一致的。/api/health这个接口对应着api.api.health.get()这种调用方式。路径中的各个部分会变成对象的属性,而HTTP方法则会成为最终的函数调用。

所有这些类型信息都是通过type Api = typeof api这一导出语句推断出来的。

类型数据是如何从服务器传递到客户端的

下面是类型数据在整个系统中的流动过程的完整示意图:

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  Drizzle模式        │     │    Elysia API    │     │   Eden Treaty   │
│  (schema.ts)     │────▶│   (api.ts)       │────▶│   (客户端代码)      │
│                  │     │                  │     │                  │
│  type User =     │     │  .get("/me",     │     │  api.api.me     │
│  typeof users    │     │    () => user)   │     │    .get()       │
│  .$inferSelect   │     │                  │     │    → { user }   │
└─────────────────┘     └─────────────────┘     └─────────────────┘

首先,Drizzle会根据你的表格定义来推断TypeScript类型。例如User这个类型就是从users表格的结构中推断出来的。

然后Elysia会在路由处理函数中使用这些类型。当某个处理函数返回{ user: session.user }时,Elysia会捕获到这个返回值的数据类型。

最后,Eden Treaty会读取type Api = typeof api这一导出语句,并生成客户端代码,在其中每个接口的定义都会包含完整的类型信息。

如果你在users表格的结构中添加了新的字段,Drizzle推断出的类型也会相应地更新。如果你的Elysia处理函数返回了这个新字段,那么Eden Treaty生成的客户端代码中的类型也会随之改变。而如果你在React组件中尝试访问一个已经不存在的字段,TypeScript会在编译阶段就捕获到这个错误。

完全不需要编写任何额外的代码,也不会产生任何运行时的开销,只是让TypeScript自动完成它的类型推断工作而已。

如何使用Eden Treaty处理错误

每次调用Eden Treaty时,都会返回一个{ data, error }类型的对象。这并不是一个被抛出的异常,而是一种“区分联合类型”,它强制你同时处理成功和失败两种情况:

const { data, error } = await api.api.me.get();

if (error) {
  // error的数据类型是根据Elysia处理函数可能返回的内容来确定的
  console.error("获取用户信息失败:", error);
  return null;
}

// data现在就被限定为成功情况下的数据类型
console.log(data.user.email);

这种模式可以有效避免那些在使用 `fetch` 或 Axios 时常见的错误——即错误被抛出后却很容易被忽略。而通过 Eden Treaty,TypeScript 编译器会提醒你这些错误。

如何在路由加载器中使用 Eden Treaty

TanStack Start 路由配置中包含了 `loader` 函数,这些函数会在服务器端进行 SSR 处理时执行,在客户端进行导航操作时也会被调用。你可以在这些加载器中使用 Eden Treaty,在页面渲染之前先获取数据:


// src/routes/_authenticated/dashboard.tsx
import { createFileRoute } from "@tanstack/react-router";

import { api } from "@/lib/treaty";

export const Route = createFileRoute("/_authenticated/dashboard")({
  loader: async () => {
    const { data } = await api.api.payments.status.get();
    return { purchase: data?.purchase ?? null };
  },
  component: DashboardPage,
});

function DashboardPage() {
  const { purchase } = Route.useLoaderData();

  return (
    

控制面板

{purchase ? (

你的套餐:{purchase.tier}

) : (

没有激活的套餐。

)}
); }

`loader` 函数会在组件渲染之前执行,因此页面在初始加载时不会出现加载提示。`Route.useLoaderData()` 会根据加载器返回的数据生成类型明确的数据。如果你更改了加载器返回的数据类型,TypeScript 会立即检测到类型不匹配的问题。

如何使用 Better Auth 添加认证功能

所有的 SaaS 服务都需要认证功能。在本教程中,你将学习如何结合 GitHub OAuth 和 Better Auth 来实现认证机制。Better Auth 是一个与特定框架无关的认证库,它可以与 Drizzle 无缝配合使用,并且也完美支持 TanStack Start。

如何创建 GitHub OAuth 应用程序

在开始编写代码之前,首先需要创建一个 GitHub OAuth 应用程序:

  1. 访问 GitHub 开发者设置

  2. 点击“新建 OAuth 应用程序”

  3. 将主页 URL 设置为 `http://localhost:3000`

  4. 将授权回调 URL 设置为 `http://localhost:3000/api/auth/callback/github`

  5. 点击“注册应用程序”

  6. 复制客户端 ID 并生成客户端密钥

将这些信息添加到项目根目录下的 `.env` 文件中:


# .env
DATABASE_URL=postgres://postgres:postgres@db.localtest.me:5432/my_saas
BETTER_AUTH_SECRET=你的随机32位字符串
BETTERAUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=你的GitHub客户端ID
GITHUB_CLIENT_SECRET=你的GitHub客户端密钥

为 `BETTER_AUTH_SECRET` 生成一个随机的密钥:

openssl rand -base64 32

如何配置认证服务器

创建文件`src/lib/auth/index.ts`。这是服务器端的认证配置代码:


// src/lib/auth/index.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";

import * as schema from 「/lib/db」;
import { db } from 「/lib/db」;

const isDev = process.env.NODE_ENV !== "production";
const baseURL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";

export const auth = betterAuth({
  baseURL,
  database: drizzleAdapter(db, {
    provider: "pg",
    usePlural: true,
    schema: {
      users: schema.users,
      sessions: schema.sessions,
      accounts: schema.accounts,
      verifications: schema.verifications,
    },
  },

  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID ?? "",
      clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
    },
  },

  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7天
    updateAge: 60 * 60 * 24,      // 每日更新
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5分钟
    },
  },

  trustedOrigins: isDev
    ? ["http://localhost:3000"]
    : [baseURL],

  plugins: [tanstackStartCookies()],
});

export type Auth = typeof auth;
export type Session = typeof auth.$Infer.Session;

此配置中的关键细节如下:

  • drizzleAdapter用于将Better Auth与您的Drizzle数据库连接起来。选项`usePlural: true`表示您的表格名称应为`users`而非`user`,`sessions`应为`sessions`而非`session`,依此类推。
  • tanstackStartCookies()是一个插件,用于处理TanStack Start的服务器端渲染过程中的cookie管理。如果没有这个插件,会话信息在服务器端渲染时将无法正确保存。
  • cookieCache会将会话数据存储在cookie中,有效期为5分钟,从而减少每次请求时对数据库的访问次数。

如何配置认证客户端

为浏览器端的认证客户端创建文件`src/lib/auth/client.ts`:


// src/lib/auth/client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: "",
});

export const { signIn, signOut, useSession } = authClient;

由于Elysia已经被集成到您的TanStack Start应用中,因此`baseURL`被设置为空字符串。认证请求会发送到同源域下的`/api/auth/*`路径,因此不需要单独的认证服务器。

如何配置认证路由

Better Auth需要处理`/api/auth/*`路径上的请求。由于Elysia已经负责处理所有`/api/*`路径的请求,因此只需将Better Auth的处理逻辑嵌入到Elysia中即可。

请在文件`src/server/api.ts`中添加以下代码:

// 在 src/server/api.ts 文件中,添加 Better Auth 的处理函数
export const api = new Elysia({ prefix: "/api" });
  // 将 Better Auth 绑定到 /api/auth/* 路由上以进行处理
  .mount(auth.handler);
  // ... 其余路由配置

.mount(authhandler) 这一行代码告诉 Elysia,将所有符合 Better Auth 路由规则的请求转发给相应的处理函数。这些请求包括登录、登出、会话管理以及 OAuth 回调操作。

如何保护路由

TanStack Start 使用布局路由来保护一组页面。请创建 src/routes/_authenticated.tsx 文件:

// src/routes/_authenticated.tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";

import { auth } from "@/lib/auth";

const getCurrentUser = createServerFn().handler(async () => {
  const rawHeaders = getRequestHeaders();
  const headers = new Headers(rawHeaders as HeadersInit);
  const session = await auth.api.getSession({ headers });
  return session?.user ?? null;
});

export const Route = createFileRoute("/_authenticated")({
  beforeLoad: async ({ location }) => {
    const user = await getCurrentUser();

    if (!user) {
      throw redirect({
        to: "/login",
        search: { redirect: location.pathname },
      });
    }

    return { user };
  },
  component: AuthenticatedLayout,
});

function AuthenticatedLayout() {
  return ;
}

_authenticated 这个前缀表示这是一个布局路由。任何嵌套在 src/routes/_authenticated/ 下的路由都会首先执行 beforeLoad 检查。如果用户尚未登录,系统会将其重定向到 /login 页面,并通过查询参数确保用户登录后能够返回到原来的页面。

createServerFn 在服务器端进行 SSR 处理时会被调用。它会读取请求中的 cookies,检查是否存在有效的会话信息,并返回当前用户的信息。这意味着身份验证操作是在任何 HTML 内容被发送到浏览器之前在服务器端完成的。

现在,任何位于 src/routes/_authenticated/ 下的文件都会自动受到保护。例如,src/routes/_authenticated/dashboard.tsx 这个文件就需要用户进行身份验证才能访问。

如何构建登录页面

src/routes/login.tsx 文件中创建登录页面:

// src/routes/login.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod";

import { signIn } from"@lib/auth/client";

const searchSchema = z.object({
  redirect: z.string().optional(),
});

export const Route = createFileRoute("/login") {
  validateSearch: searchSchema,
  component: LoginPage,
};

functionLoginPage() {
  const { redirect: redirectTo } = Route.useSearch();
  const [isLoading, setIsLoading] = useState(false);

  const handleGitHubLogin = async () => {
    setIsLoading(true);
    const callbackURL = redirectTo
      ? `\({window.location.origin}\){redirectTo}`
      : `${window.location.origin}/dashboard`;

    await signIn.social({
      provider: "github",
      callbackURL,
    });
  };

  return (
    

登录

); }

TanStack Router的validateSearch方法会使用Zod库来验证查询参数。redirect参数被定义为可选的字符串类型,而Route.useSearch()会返回一个类型安全的对象,因此无需进行手动解析。

如何添加登录重定向中间件

对于已经通过身份验证的用户,你也应该将他们重定向到其他页面,而不是登录页面。你可以在src/start.ts文件中创建相应的代码:

// src/start.ts
import { redirect } from "@tanstack/react-router";
import { createMiddleware, createStart } from "@tanstack/react-start";
import { getRequestHeaders, getRequestUrl } from "@tanstack/react-start/server";

import { auth } from "@/lib/auth";

const authMiddleware = createMiddleware({ type: "request" }).server(
  async ({ next }) => {
    const rawHeaders = getRequestHeaders();
    const headers = new Headers(rawHeaders as HeadersInit);
    const url = getRequestUrl();

    if (url.pathname !== "/login") {
      return next();
    }

    const session = await auth.api.getSession({ headers });

    if (session?.user) {
      const redirectTo = url.searchParams.get("redirect");
      throw redirect({
        to: redirectTo || "/dashboard",
      });
    }

    return next();
  }
);

export const startInstance = createStart(() => ({
  requestMiddleware: [authMiddleware],
]));

这个中间件会在每一个请求被处理时被执行。如果用户已经通过了身份验证,但仍然访问了/login页面,他们就会被重定向到仪表板页面(或者他们原本想要访问的任何页面)。

如何使用四层架构模式构建完整的功能

现在你已经拥有了数据库、API、类型安全的客户端以及身份验证机制,是时候开始构建具体的功能了。在这个架构中,每一个功能都是按照相同的四层模式来设计的:

本教程中使用的四层功能架构模式:第1层为数据结构定义,第2层提供CRUD操作接口,第3层将React与API连接起来,第4层负责用户界面的渲染及交互处理

一旦你理解了这种架构模式,添加新的功能就会变得非常简单。让我们一起来构建一个允许已认证用户查看购买记录的功能吧。

第1层:数据结构定义

你之前已经在数据结构定义中创建了purchases表,下面是具体的代码:

// src/lib/db/schema.ts
export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment(intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  creadoAt: timestamp("created_at").notNull().defaultNow(),
 .updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

如果你要添加新功能,就应该从这里开始。首先定义相关表格结构,然后运行 `bun run db:push` 命令,接着进入第二层开发流程。

第二层:API开发

在 `src/server/routes/purchases.ts` 文件中创建一个API路由模块:


// src/server/routes/purchases.ts
import { eq } from "drizzle-orm";
import { Elysia } from "elysia";

import { auth } from "@/lib/auth";
import { db, purchases } from "@lib/db";

export const purchasesRoute = new Elysia({ prefix: "/purchases" })
  .get("/status", async ({ request, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session?.user) {
      set.status = 401;
      return { error: "未经授权" };
    }

    const purchase = await db
      .select()
      .from(purchases)
      .where(eq(purchases.userId, session.user.id))
      .limit(1);

    return purchase[0] ?? null;
  });

然后在你主API文件中注册这个路由模块:


// src/server/api.ts
import { purchasesRoute } from "./routes/purchases";

export const api = new Elysia({ prefix: "/api" })
  .mount(auth.handler)
  .use(purchasesRoute)
  // ... 其他路由配置

`.use()` 方法用于将各个路由模块组合到一起。每个路由模块都是一个独立的Elysia实例,它们各自拥有自己的前缀,而 `use` 方法会将这些模块整合到主应用程序中。由于Eden Treaty能够识别这种组合后的结构,因此客户端会自动知道新增的接口地址。

第三层:钩子函数

创建一个自定义钩子函数,将你的React组件与API连接起来:


// src/hooks/use-purchase-status.ts
import { useQuery } from "@tanstack/react-query";

import { api } from "@lib/treaty";

export function usePurchaseStatus() {
  return useQuery({
    queryKey: ["purchase-status"],
    queryFn: async () => {
      const { data, error } = await api.api.purchases.status.get();
      if (error) throw new Error("获取购买状态信息失败");
      return data;
    },
  });
}

TanStack Query负责处理缓存、数据重新请求、加载状态以及错误处理等功能。`queryKey`用于在缓存中识别这些数据。如果多个组件调用了 `usePurchaseStatus()`,系统也只会发起一次网络请求。

对于数据的创建、更新或删除操作,可以使用 `useMutation` 钩子函数:


// src/hooks/use-checkout.ts
import { useMutation } from "@tanstack/react-query";

import { api } from "@lib/treaty";

export function useCheckout() {
  return useMutation({
    mutationFn: async () => {
      const { data, error } = await api.api.payments.checkout.post();
      if (error) throw new Error("创建结账会话失败");
      return data;
    },
    onSuccess: (data) => {
      // 重定向到Stripe结算页面
      if (data?.url) {
        window.location.href = data.url;
      }
    },
  });
}

第四层:用户界面

在您的React组件中使用这些钩子:

// src/components/purchase-status.tsx
import { usePurchaseStatus } from 「@hooks/use-purchase-status";

export function PurchaseStatus() {
  const { data: purchase, isLoading, error } = usePurchaseStatus();

  if (isLoading) {
    return 
正在加载中...
; } if (error) { return
无法加载购买状态。
; } if (!purchase) { return (

尚未购买任何套餐。

您还没有购买任何套餐。

); } return (

状态:{purchase.status}

各层之间的交互方式

以下是数据在四层结构中流动的具体过程(以读取操作为例):

用户点击“仪表盘”
  → TanStack Router触发路由加载器
    → 加载器通过Eden Treaty调用api.api.purchases.status.get()
      → Elysia接收到GET /api/purchases/status请求
        → 处理程序调用auth.api.getSession()来验证用户身份
        → 处理程序通过Drizzle查询db.select().from(purchases)
        → 处理程序返回包含类型信息的{purchase}对象
      → Eden Treaty接收到格式化后的响应数据
    → 加载器返回处理后的数据
  → 组件利用Route.useLoaderData()来渲染页面

对于写入操作(例如创建新资源),数据流动的过程类似,但会使用mutation机制:

用户点击“立即购买”
  → onClick通过useMutation钩子调用checkout.mutate()
    → mutationFn通过Eden Treaty调用api.api.payments.checkout.post()
      → Elysia接收到POST /api/payments/checkout请求
        → 处理程序创建Stripe支付会话
        → 处理程序返回支付链接(url)
      → Eden Treaty接收到格式化后的响应数据
    → 成功后系统会重定向到Stripe支付页面

如何添加新功能

为了进一步说明这一结构,我们来看一下如何添加用户资料更新功能。这个例子会展示整个四层结构在写入操作中的应用过程。

第一层:数据模型。 `users`表中已经存在可以更新的`name`字段,因此不需要修改数据模型。

第二层:API。 需要添加一个PATCH接口:

// 在src/server/api.ts文件中
patch(
  "/me",
  async ({ request, body, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "未经授权" };
    }

    const [updatedUser] = await db
      .update(users)
      .set({
        name: body.name,
        updatedAt: new Date(),
      })
      .where(eq(users.id, session.user.id))
      .returning();

    return { user: updatedUser };
  },
  {
    body: t.Object({
      name: t.String({ minLength: 1, maxLength: 100 }),
    }),
  },
)

body选项会在运行时验证请求体,并在编译时提供TypeScript类型定义。如果有人发送的请求中缺少name字段,Elysia会自动返回400错误代码。因此,你无需自行编写任何验证逻辑。

第三层:Hook函数。 需要创建一个mutation hook函数:

// 在src/hooks/use-update-profile.ts文件中
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { api } from "@/lib/treaty";

export function useUpdateProfile() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: { name: string }) => {
      const { data: result, error } = await api.api.me.patch(data);
      if (error) throw new Error("更新个人资料失败");
      return result;
    },
    onSuccess: () => {
      // 清除所有依赖于用户数据的查询缓存
      queryClientinvalidateQueries({ queryKey: ["me"] });
    },
  });
}

onSuccess回调函数会清除与用户数据相关的查询缓存。这意味着任何显示用户信息的组件都会自动重新获取数据并显示更新后的姓名。

第四层:用户界面。 需要在表单组件中使用这个hook函数:

// 在src/components/profile-form.tsx文件中
import { useState } from "react";

import { useUpdateProfile } from "@/hooks/use-update-profile";

export function ProfileForm({ currentName }: { currentName: string }) {
  const [name, setName] = useState(currentName);
  const updateProfile = useUpdateProfile();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    updateProfile.mutate({ name });
  };

  return (
    
显示名称 {updateProfile.isPending ? "保存中..." : "保存"} {updateProfile.isError && (

)} > ); }

共有四层结构,每层都遵循相同的模式。

这种重复性是故意设计的——重复本身是一种设计特征,而非错误。当所有组件都遵循相同的结构时,人们就能清楚地知道该去哪里查找所需的信息。

新代码会被添加到预定的位置中。如果你使用人工智能编码辅助工具,它可以从你的代码库中学习这种模式,并为新的功能生成全部四层结构。

如何使用Stripe添加支付功能

大多数SaaS应用程序都需要处理支付业务。对于一次性购买场景,你可以使用Stripe Checkout来实现支付功能。关键在于要使用后台作业来可靠地处理Webhook请求,具体实现方法将在下一节中介绍。

如何配置Stripe

创建文件src/lib/payments/index.ts


// src/lib/payments/index.ts
import Stripe from "stripe";

let stripeClient: Stripe | null = null;

function getStripe(): Stripe {
  if (!stripeClient) {
    const secretKey = process.env.STRIPE_SECRET_KEY;
    if (!secretKey) {
      throw new Error(
        "STRIPE_SECRET_KEY未设置,因此支付功能无法使用。"
      );
    }
    stripeClient = new Stripe(secretKey);
  }
  return stripeClient;
}

// 使用Proxy机制来延迟初始化Stripe SDK,这样即使环境变量缺失,模块导入也不会出错
export const stripe = new Proxy({} as Stripe, {
  get(_, prop) {
    return Reflect.get(getStripe(), prop);
  },
});

export async function createOneTimeCheckoutSession(params: {
  priceId: string;
  successUrl: string;
  cancelUrl: string;
  metadata: Record;
  customerEmail?: string;
  couponId?: string;
}) {
  const client = getStripe();

  const session = await client.checkout Sessions.create({
    mode: "payment",
    line_items: [{ price: params.priceId, quantity: 1 }],
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    metadata: params.metadata,
    ...(params.customerEmail && {
      customer_email: params.customerEmail,
    }),
    ...(params.couponId
      ? { discounts: [{ coupon: params.couponId }] }
      : { allow_promotion_codes: true },
  });

  return session;
}

export async function retrieveCheckoutSession(sessionId: string) {
  const client = getStripe();
  return client.checkoutSessions.retrieve(sessionId);
}

export async function constructWebhookEvent(
  payload: string | Buffer,
  signature: string
) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) {
    throw new Error("STRIPE/WebHOOK_SECRET未设置");
  }
  const client = getStripe();
  return client.webhooks.constructEventAsync(payload, signature, webhookSecret);
}

对于Stripe客户端来说,使用Proxy机制是一种成熟的开发技巧。这种机制可以延迟初始化Stripe SDK,因此即使环境变量STRIPE_SECRET_KEY缺失,模块导入也不会出错。这在构建项目或某些服务尚未配置的环境中非常有用。

如何创建结账端点

在您的API中添加一个结账端点:

// 在src/server/api.ts文件中
.post("/payments/checkout", async ({ set }) => {
  const priceId = process.env.STRIPE_PRO PRICE_ID;

  if (!priceId) {
    set.status = 500;
    return { error: "价格信息未配置" };
  }

  const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";

  const checkoutSession = await createOneTimeCheckoutSession({
    priceId,
    successUrl: `${baseUrl}/dashboard?purchase=success&session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${baseUrl}/pricing`,
    metadata: { tier: "pro" },
  });

  return { url: checkoutSession.url };
})

{CHECKOUT SESSION_ID}这个占位符是Stripe提供的模板变量。当Stripe将用户重定向回您的应用程序时,它会用实际的会话ID来替换这个占位符。

如何处理Webhook事件

当支付交易完成时,Stripe会发送Webhook事件。您的Webhook处理程序需要验证签名、解析事件内容,并对事件进行相应的处理。

这里有一个重要的设计原则:不要在Webhook处理程序中执行复杂的处理操作。Stripe要求您在几秒钟内做出响应;如果处理时间过长,Stripe会重新尝试发送Webhook事件,这可能会导致数据被重复处理。

因此,应该采用“接收Webhook事件后由后台作业进行处理”的模式:

// 在src/server/api.ts文件中
.post("/payments/webhook", async ({ request, set }) => {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  if (!sig) {
    set.status = 400;
    return { error: "签名信息缺失" };
  }

  try {
    const event = await constructWebhookEvent(body, sig);
    console.log(`[Webhook] 收到类型为 ${event.type} 的事件`);
    
    if (event.type === "charge.refunded") {
      const charge = event.data.object as {
        id: string,
        payment(intent: string,
        amount: number,
        amount_refunded: number,
        currency: string,
      };
      await inngest.send({
        name: "stripe/charge/refunded",
        data: {
          chargeId: charge.id,
          paymentIntentId: charge.paymentintent,
          amount_refunded: charge.amount_refunded,
          originalAmount: charge.amount,
          currency: charge.currency,
        },
      });
    }

    return { received: true };
  } catch (error) {
    console.error("[Webhook] Stripe签名验证失败:", error);
    set.status = 400;
    return { error: "Webhook签名验证失败" };
  }
})

Webhook处理程序主要完成三项任务:验证签名、确定事件类型,然后将数据转发给后台作业进行进一步处理。它会立即返回{ received: true }这一响应信号;而实际的业务逻辑操作(如发送邮件、授权用户或更新数据库记录)则由后台作业来完成,这部分内容我们接下来会进行开发。

如何在前端申请购买款项的退款

在成功完成结账流程后,Stripe会将用户重定向回您的应用程序,并附带一个会话ID。您需要创建一个端点,通过验证该会话ID并在数据库中创建相应记录来申请退款:

// 在src/server/api.ts文件中
.post(
  "/purchases/claim",
  async ({ body, request, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "未经授权" };
    }

    const { sessionId } = body;

    // 检查该购买记录是否已被申请退款
    const existing = await db
      .select()
      .from(purchases)
      .where(eq(purchases.stripeCheckoutSessionId,sessionId))
      .limit(1);

    if (existing[0]) {
      return { success: true, alreadyClaimed: true, tier: existing[0].tier };
    }

    // 验证付款状态
    const stripeSession = await retrieveCheckoutSession(sessionId);

    if (stripeSession.payment_status !== "paid") {
      set.status = 400;
      return { error: "付款未完成" };
    }

    const tier = (stripeSession.metadata?.tier ?? "pro") as "pro";

    // 创建购买记录
    await db.insert(purchases).values({
      userId: session.user.id,
      stripeCheckoutSessionId: sessionId,
      stripeCustomerId:
        typeof stripeSession.customer === "string"
          ? stripeSession.customer
          : stripeSession.customer?.id ?? null,
      stripePaymentIntentId:
        typeof stripeSession.payment(intent === "string"
          ? stripeSession.paymentintent
          : stripeSession.payment_intent?.id ?? null,
      tier,
      status: "completed",
      amount: stripeSession.amount_total ?? 0,
      currency: stripeSession.currency ?? "usd",
    });

    // 触发后台处理流程
    await inngest.send({
      name: "purchase/completed",
      data: {
        userId: session.user.id,
        tier,
        sessionId,
      },
    });

    return { success: true, tier };
  },
  {
    body: t.Object({
     sessionId: t.String(),
    }),
  }
)

请注意,代码中进行了幂等性检查。如果用户重新访问成功页面,或者前端再次尝试申请退款,该端点会返回已存在的购买记录,而不会创建重复记录。

这对于确保支付流程的准确性至关重要——您绝对不能意外地让某人被收取两次费用,也不能创建重复的记录。

调用`inngest.send()`会触发购买款项的后台处理流程。通过这个流程,您可以发送确认邮件、授予用户访问资源的权限、跟踪分析数据,以及执行任何其他售后操作。

如何在本机环境中测试支付功能

首先安装Stripe CLI,然后将Webhook回调地址设置为您的本地服务器:

# 在macOS系统上安装Stripe CLI
brew install stripe/stripe-cli/stripe

# 登录Stripe账户
stripe login

# 将Webhook回调地址设置为本地服务器
stripe listen --forward-to localhost:3000/api/payments/webhook

Stripe CLI会提供一段以whsec_开头的Webhook签名密钥。请将其添加到您的.env文件中:

STRIPE_WEBHOOK_SECRET=whsec_your-local-webhook-secret

在Stripe的控制面板中创建一个测试产品并设置其价格(或者使用Stripe CLI),然后将该价格的ID添加到您的.env文件中:

STRIPE_SECRET_KEY=sk_test_your-test-secret-key
STRIPE_PRO PRICE_ID=price_your-test-price-id

如何使用Inngest添加后台任务

对于任何SaaS产品来说,后台任务都是至关重要的。您可以使用它们来处理Webhook请求、发送电子邮件、授予用户访问资源的权限,以及执行那些不会阻塞API响应的操作。Inngest提供了具备内置检查点功能的可靠且可重试的任务执行服务。

为什么后台任务如此重要

想象一下,当有人购买了您的SaaS产品时,会发生以下这些步骤:

  1. Stripe会验证付款信息。

  2. 系统会在数据库中创建购买记录。

  3. 会向客户发送确认邮件。

  4. 也会向管理员发送通知邮件。

  5. 会允许用户访问私有的GitHub仓库。

  6. 会在分析平台上跟踪这次购买事件。

  7. 还会安排后续的电子邮件发送流程。

如果尝试在单个API接口中完成所有这些步骤,很可能会出现问题。例如,邮件服务可能会出现故障,GitHub API可能会设置访问速率限制,或者分析请求可能会超时。

任何一次失败都会导致用户看到错误信息,而您则需要弄清楚哪些步骤已经成功执行,哪些步骤没有完成。

Inngest通过提供可靠的任务执行机制来解决这些问题。每个步骤都会被标记为检查点。如果第3步失败了,Inngest会重新尝试执行第3步,而不会重新运行第1步和第2步。

如果整个任务流程都失败了,Inngest也会重新尝试整个流程。这样,您至少能够确保任务会被执行一次,并且系统还会自动避免重复执行相同的操作。

如何设置Inngest

src/lib/jobs/client.ts文件中创建Inngest客户端:

// src/lib/jobs/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-saas",
});

如何编写您的第一个Inngest函数

src/lib/jobs/functions/stripe.ts文件中编写处理购买完成事件的函数:

// src/lib/jobs/functions/stripe.ts
import { eq } from "drizzle-orm";

import { inngest } from "../client";
import { db, purchases, users } from "@/lib/db";

export const handlePurchaseCompleted = inngest.createFunction(
  {
    id: "purchase-completed",
    triggers: [{ event: "purchase/completed" }],
  },
  async ({ event, step }) => {
    const { userId, tier, sessionId } = event.data as {
      userId: string;
      tier: string;
      sessionId: string;
    };

    // 第1步:查询用户信息和购买记录
    const { user, purchase } = await step.run(
      "lookup-user-and-purchase",
      async () => {
        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
          })
          .from(users)
          .where(eq(users.id, userId))
          .limit(1);

        const foundUser = userResult[0];
        if (!foundUser) {
          throw new Error(`未找到用户:${userId}`);
        }

        const purchaseResult = await db
          .select({
            amount: purchases.amount,
            currency: purchases.currency,
          })
          .from(purchases)
          .where(eq(purchases.stripeCheckoutSessionId, sessionId))
          .limit(1);

        return {
          user: foundUser,
          purchase: purchaseResult[0] ?? {
            amount: 0,
            currency: "usd",
          },
        };
      }
    );

    // 第2步:发送购买确认邮件
    await step.run("send-purchase-confirmation", async () => {
      // 使用您的邮件服务发送确认邮件(例如Resend、SendGrid等)
      console.log(
        `正在向${user.email}发送购买确认邮件`
      );
      // // await sendEmail({
      //   to: user.email,
      //   subject: "您的购买已确认!",
      //   template: PurchaseConfirmationEmail,
      // });
    });

    // 第3步:通知管理员
    await step.run("send-admin-notification", async () => {
      const adminEmail = process.env ADMIN_EMAIL;
      if (!adminEmail) return;

      console.log(
        `正在通知管理员关于${user.email}的购买信息`
      );
      // // await sendEmail({
      //   to: adminEmail,
      //   subject: `新销售记录:${user.email}`,
      //   template: AdminNotificationEmail,
      // });
    });

    // 第4步:更新购买记录
    await step.run("update-purchase-record", async () => {
      await db
        .update(purchases)
        .set({.updated: new Date() })
        .where(eq(purchases.stripeCheckoutSessionId, sessionId));
    });

    return { success: true, userId, tier };
  }
);

export const stripeFunctions = [handlePurchaseCompleted];

每个`step.run()`都代表一个检查点。如果函数在执行到第2步时失败,Inngest会从第3步开始重新尝试,而不会从头开始。已完成步骤的结果会被缓存起来。

如何注册你的函数

创建一个索引文件,用于收集你所有的函数:

// src/lib/jobs/functions/index.ts
import { stripeFunctions } from "./stripe";

export const functions = [...stripeFunctions];

同时还需要进行导出操作:

// src/lib/jobs/index.ts
export { inngest } from "./client";
export { functions } from "./functions";

如何将Inngest连接到你的API

在Elysia API中配置Inngest处理程序。将其添加到`src/server/api.ts`文件中:

// src/server/api.ts
import { serve } from "inngest/bun";

import { inngest, functions } from "@/lib/jobs";

const inngestHandler = serve({
  client: inngest,
  functions,
});

export const api = new Elysia({ prefix: "/api" })
  // Inngest端点——用于处理函数的注册和执行
  .all("/inngest", async (ctx) => {
    return inngestHandler(ctx.request);
  })
  // ... 其他路由配置

`/inngest`这个路由可以处理来自Inngest的GET请求(用于函数注册)和POST请求(用于函数执行)。

如何在本地运行Inngest

Inngest提供了一个可以在本地运行的开发服务器,并提供了用于监控函数的仪表板:

npx inngest-cli@latest dev -u http://localhost:3000/api/inngest --no-discovery

这样就会在`http://localhost:8288`地址启动Inngest开发服务器。在浏览器中打开这个网址,你就能看到显示已注册函数、事件记录以及函数执行日志的仪表板。

`-u`选项用于指定你的应用程序运行所在的地址;`--no-discovery`选项则可以禁用自动应用发现功能,这对于本地开发来说更为可靠。

你可以将这段命令添加到`package.json`文件中的`scripts`部分:

{
  "scripts": {
    "inngest:dev": "npx inngest-cli@latest dev -u http://localhost:3000/api/inngest --no-discovery"
  }
}

现在你可以通过API发送事件来触发函数的执行:

await inngest.send({
  name: "purchase/completed",
  data: {
    userId: "user_123",
    tier: "pro",
    sessionId: "cs_test_abc",
  },
});

该事件会出现在Inngest仪表板上,函数会逐步执行,你可以看到每一步的执行结果。如果某一步失败了,你可以在仪表板上手动重新尝试。

如何使用后台作业处理退款操作

下面是一个更复杂的例子,它说明了为什么可靠的执行机制如此重要。在处理退款操作时,你需要更新购买状态、取消用户的访问权限、发送通知以及进行相关数据分析。如果其中任何一个步骤失败了,其余的步骤仍然应该能够顺利完成:

// src/lib/jobs/functions/stripe.ts
export const handleRefund = inngest.createFunction(
  {
    id: "refund-processed",
    triggers: [{ event: "stripe/charge.refunded" }],
  },
  async ({ event, step }) => {
    const { paymentIntentId, amountRefunded, originalAmount, currency } =
      event.data as {
        chargeId: string;
        paymentIntentId: string;
        amountRefunded: number;
        originalAmount: number;
        currency: string;
      };

    const isFullRefund = amountRefunded >= originalAmount;

    // 第一步:查找相应的购买记录和用户信息
    const { user, purchase } = await step.run(
      "lookup-purchase",
      async () => {
        const purchaseResult = await db
          .select()
          .from(purchases)
          .where(eq(purchases.stripePaymentIntentId, paymentIntentId))
          .limit(1);

        if (!purchaseResult[0]) {
          return { user: null, purchase: null };
        }

        const userResult = await db
          .select()
          .from(users)
          .where(eq(users.id, purchaseResult[0].userId))
          .limit(1);

        return {
          user: userResult[0] ?? null,
          purchase: purchaseResult[0],
        };
      }
    );

    if (!purchase || !user) {
      return { success: false, reason: "no_matching_purchase" };
    }

    // 第二步:更新购买状态
    await step.run("update-purchase-status", async () => {
      await db
        .update(purchases)
        .set({
          status: isFullRefund ? "refunded" : "partially_refunded",
         .updated: new Date(),
        })
        .where(eq(purchases.id, purchase.id));
    });

    // 第三步:向客户发送通知
    await step.run("notify-customer", async () => {
      console.log(
        `正在向 \{user.email}\ 发送 \({isFullRefund ? "全额退款" : "部分退款"} 的通知`
      );
      // await sendEmail({ ... });
    });

    return { success: true, isFullRefund };
  }
);

即使在第三步中电子邮件发送服务出现故障,第二步(更新数据库的操作)也已经完成,因此不会被重新执行。inngest只会重试那些失败了的步骤。

正是这种可靠的执行机制使得支付处理过程更加高效。你无需自己编写重试逻辑,就能获得稳定、可重复执行的处理结果。

如何使用Neon将应用部署到Vercel上

现在,你的应用程序已经具备了认证功能、数据库支持、类型安全的API接口以及后台处理机制。是时候将其部署到生产环境了。

如何配置Neon数据库

  1. 访问neon.tech注册新项目

  2. 选择距离你的用户群体较近的AWS区域

  3. 从控制面板中复制数据库连接字符串

连接字符串的格式如下:

postgresql://username:password@ep-something.us-east-1.aws.neon.tech/my_saas?sslmode和要求

如何在生产环境中运行迁移操作

在生产环境中,应使用带有版本号的迁移文件,而不是db:push命令。首先根据你的数据库架构生成相应的迁移文件:

bun run db:generate

这样会生成一些SQL脚本文件,这些文件会被保存在drizzle/目录中。请仔细检查生成的SQL代码,确保它们符合你的需求。之后再执行迁移操作:

DATABASE_URL="your-neon-connection-string" bun run db:migrate

如何将应用部署到Vercel平台上

  1. 将你的代码上传到GitHub仓库中。

  2. 访问vercel.com/new,然后导入你的GitHub仓库。

  3. Vercel会自动检测并配置相关的构建设置。

在Vercel的仪表板上设置以下环境变量:

变量名
DATABASE_URL 你的Neon连接字符串
BETTER_AUTH_SECRET 你生成的32个字符以上的随机密码
BETTER AUTH_URL https://your-app.vercel.app
GITHUB_CLIENT_ID 你的GitHub OAuth客户端ID
GITHUB_CLIENT_SECRET 你的GitHub OAuth客户端密钥
STRIPE_SECRET_KEY 你的Stripe秘密密钥
STRIPE_WEBHOOK_SECRET 你的Stripe Webhook秘密密钥(用于生产环境)
STRIPE_PRO PRICE_ID 你的Stripe价格ID

点击“Deploy”按钮,Vercel会构建你的应用并将其部署到.vercel.app地址上。

如何更新OAuth回调地址

部署完成后,请更新你的GitHub OAuth应用的回调URL:

  1. 进入你的GitHub OAuth应用设置页面。

  2. 授权回调URL修改为https://your-app.vercel.app/api/auth/callback/github

  3. https://your-app.vercel.app设置为首页URL

如何为生产环境配置Stripe Webhook

在Stripe的仪表板上创建一个Webhook端点:

  1. 访问Stripe仪表板 > 开发者 > Webhook

  2. 点击“添加端点”按钮。

  3. 将URL设置为https://your-app.vercel.app/api/payments/webhook

  4. 选择你希望接收的事件类型(例如charge.refundedcheckout.session.expired等)。

  5. 复制Webhook签名密钥,并将其添加到Vercel的环境变量中。

如何在生产环境中配置Inngest

Inngest提供了一项云服务,用于处理生产环境中的函数执行任务:

  1. 请访问inngest.com进行注册。

  2. 创建一个应用,并复制你的事件密钥和签名密钥。

  3. INNGEST_EVENT_KEYINNGEST_SIGNING_KEY添加到Vercel的环境变量中。

  4. 在Inngest的仪表板中,将你的应用URL设置为https://your-app.vercel.app/api/inngest

Inngest会自动检测到你的函数并开始处理相关事件。

常见的部署陷阱

1. SSR外部依赖项。某些包不适用于Vite的SSR打包机制。如果在构建过程中遇到与elysiainngest相关的错误,请将它们添加到vite.config.ts文件中的ssr.external数组中:

// vite.config.ts
export default defineConfig({
  ssr: {
    external: ["elysia", "inngest"],
  },
  // ...
});

2>环境变量的访问权限。在TanStack Start环境中,服务器端代码可以直接访问process.env;而客户端代码只能访问以VITE_为前缀的环境变量。你的Stripe密钥和数据库URL绝对不能带有VITE_前缀。

3>Neon连接池的使用。在生产环境中,应使用Neon提供的连接池字符串(该连接池使用端口5432,而非直接连接的端口5433)。连接池能够更有效地处理并发请求。

4>构建失败的问题。如果构建过程中出现错误,最常见的原因通常是TypeScript相关的问题。在上传代码之前,请先在本地运行bun run type-check命令进行检查。在部署之前务必修复所有错误。

5>环境变量缺失的问题。如果应用在部署后立即崩溃,请查看Vercel的功能日志。最常见的原因就是缺少某个环境变量。Neon连接字符串、Stripe密钥以及Better Auth相关的配置信息,都必须在首次部署之前设置完毕。

如何设置自定义域名

一旦你的应用被部署到Vercel上:

  1. 进入Vercel平台,查看你项目的设置选项。

  2. 点击“域名”选项。

  3. 添加你的自定义域名。

  4. 按照提示更新DNS记录(通常需要添加一条指向cname.vercel-dns.com的CNAME记录)。

添加自定义域名后,需要在Vercel中更新以下环境变量:

  • BETTER_AUTH_URL设置为https://yourdomain.com

  • 将你的GitHub OAuth应用的回调URL更新为https://yourdomain.com/api/auth/callback/github

  • 将Stripe Webhook的端点地址更新为https://yourdomain.com/api/payments/webhook

Vercel会自动为您的自定义域名配置SSL证书,无需进行任何额外设置。

如何验证您的部署环境

部署完成后,请按照以下步骤进行检查:

  1. 健康检查。访问https://yourdomain.com/api/health,系统应会返回一个包含{ "status": "ok" }的JSON响应。

  2. 身份验证。点击“使用GitHub登录”并完成OAuth认证流程,随后您应该会被重定向到控制面板。

  3. 数据库检查。登录后查看Neon控制面板,您会在users表中看到新添加的记录。

  4. 支付功能测试。在定价页面点击“购买”,使用Stripe的测试卡(4242 4242 4242 4242)完成交易,确认数据库中出现了相应的购买记录。

  5. 后台任务运行情况。进行测试购买后,查看Inngest控制面板,应能看到purchase/completed事件以及对应的函数执行记录。

如果其中任何步骤出现故障,请查阅Vercel的功能日志(设置→功能→日志)以查找错误信息。大多数部署问题都是由于环境变量配置错误或webhook密钥缺失造成的。

总结

您刚刚搭建了一个可投入生产环境的SaaS应用。下面我们来回顾一下所使用的技术组件:

  • TanStack Start负责处理服务器端渲染、基于文件的路由配置以及开发服务器的运行。

  • Elysia提供了一个类型安全的API,该API与您的Web应用运行在同一个进程中。

  • Eden Treaty提供了无需代码生成即可使用的完整类型化API客户端。

  • Drizzle ORM与Neon结合使用,支持类型安全的数据库查询操作,并利用无服务器架构的PostgreSQL进行数据管理。

  • Better Auth通过GitHub OAuth实现用户身份验证,同时提供会话管理和路由保护功能。

  • Stripe负责处理支付事务,并支持webhook接口。

  • Inngest能够自动重试并保存中间结果,从而确保后台任务的可靠运行。

  • Vercel完全免去了基础设施管理的麻烦,帮助您轻松部署整个应用。

  • 这种四层架构模式(数据模型、API接口、钩子函数、用户界面)使得添加新功能时能够遵循统一的流程:首先定义数据结构,通过API暴露这些数据,利用钩子函数将它们与React组件连接起来,最后在用户界面上展示结果。

    这种架构具有良好的扩展性。由于各层之间有明确的边界,因此您可以随时替换其中某个组件而无需重新编写全部代码。

    如果Neon无法满足您的需求,您可以切换到自托管的PostgreSQL数据库;如果需要更换支付服务提供商,只需替换Stripe模块即可,其余部分的应用逻辑不会受到影响。

    接下来您要做什么完全取决于您自己的计划。以下是一些常见的后续发展方向:

    采用`src/lib/`这种目录结构,添加新的集成模块会变得非常方便。只需创建一个新的目录,编写一个`index.ts`文件,然后在其需要的地方导入即可。每个集成模块都是独立运行的,因此添加数据分析功能不会影响到支付相关代码的运行。

    如果你想跳过设置步骤,立即开始开发产品,Eden Stack提供了本文中提到的所有功能(甚至更多),这些功能都已经经过预先配置和生产环境测试。它还内置了30多种Claude Code技能,这些技能能够帮助AI编码助手根据你的代码规范自动生成所需的功能。

    无论你开发什么产品,都应该确保其具备类型安全性。“修改数据结构 → 查看错误 → 修复错误”这一反馈循环,是我所知道的最快的方式,能够帮助你开发出可靠的软件。

    Magnus Rodseth专注于开发基于AI的应用程序,同时也是Eden Stack的创建者。Eden Stack是一个为SaaS开发准备的入门套件,其中包含了30多种Claude Code技能,这些技能能够帮助开发者遵循生产环境中的最佳实践进行开发。

    ]]> 使用 Claude 和 Obsidian 自动化你的销售流程 http://www.cheeli.com.cn/articles/automate-your-sales-pipeline-with-claude-and-obsidian/ Thu, 02 Apr 2026 20:18:39 +0000 http://www.cheeli.com.cn/?p=21026 Read More]]> 对许多软件工程师和SaaS企业的创始人来说,编写代码其实是一件相对容易的事情。然而,市场营销和销售工作却仿佛是一场艰难的斗争。如果你更愿意专注于开发新功能,而不是忙于寻找潜在客户,那么你应该观看我们刚刚发布在freeCodeCamp.org YouTube频道上的视频。这个视频的嘉宾是Strategy Sprints公司的首席执行官Simon Severino。

    在这次与Haider Malik的对话中,Simon详细介绍了自己是如何利用人工智能来自动化整个销售流程的。通过这种方式,他原本需要花费8小时来完成的工作,现在只需10分钟就能完成。

    Simon还解释了如何利用人工智能来支持45名虚拟助手来处理各种任务,从寻找潜在客户到执行繁琐的行政工作。他将Claude通过终端连接到Obsidian、Notion、Granola和Hunter等工具中,从而创建了一个能够完成以下任务的系统:

    • 潜在客户开发:明确理想客户的特征,然后有针对性地寻找潜在客户。

    • 自动化沟通:编写看似自然、能够有效吸引受众的冷邮件,并通过A/B测试来优化发送策略。

    • 每日工作安排:使用/today命令从Slack、Gmail和日历中收集信息,从而确定当天需要优先处理的任务。

    • 知识管理:将会议中产生的“隐性知识”以结构化的markdown格式保存在Obsidian中,以便人工智能能够随时参考这些信息。

    这个系统的目的就是减少人类的“认知负担”。通过将行政性工作交给人工智能来处理,你就可以把时间花在制定战略上。

    请在freeCodeCamp.org的YouTube频道观看这段视频(时长超过1小时)。

    ]]>
    在 Flutter 中使用 IndexedStack 实现高效的状态管理 http://www.cheeli.com.cn/articles/efficient-state-management-in-flutter-using-indexedstack/ Wed, 01 Apr 2026 20:20:47 +0000 http://www.cheeli.com.cn/?p=21022 Read More]]> 在开发具有多个标签页或屏幕的Flutter应用程序时,你将会面临的一个最常见的问题就是:如何在导航过程中保持状态的一致性,同时又不会破坏用户体验。当用户切换标签页时,如果突然丢失了滚动位置、表单输入内容或之前加载的数据,这个问题就会变得非常明显。

    这个问题的产生并不是因为Flutter效率低下,而通常是由于在导航过程中组件会被重新构建所导致的。

    一个实用且常常被忽视的解决方案就是使用IndexedStack组件。它允许你在切换屏幕的同时保持它们的状态不变,从而使得导航过程更加流畅,性能也更好。

    本文将深入探讨IndexedStack的工作原理、它的重要性以及如何在实际应用程序中正确使用它。

    目录

    • 先决条件

    • 标签页导航的真实问题

    • 默认行为的可视化演示

    • 了解IndexedStack

    • 构建任务管理器示例

    • 处理每个标签页的独立导航功能

    • 将IndexedStack与状态管理结合使用

      先决条件

      为了能够顺利跟随学习流程,你应当已经了解Flutter部件的工作原理,尤其是StatelessWidgetStatefulWidget之间的区别。

      你还应该熟悉ScaffoldBottomNavigationBar,以及当状态发生变化时Flutter是如何重新构建部件的。

      最后,对部件树的工作方式有一个基本的了解,将有助于你更清晰地理解相关概念。

      标签页导航存在的真正问题

      实现标签页导航的一种常见方法是这样的:

      body: _tabs[_currentIndex],

      乍一看,这种方式似乎很合理,也适用于简单的情况。但实际上,每当索引发生变化时,系统内部都会发生一些重要的操作。

      Flutter会将当前显示的部件从组件树中移除,然后重新创建一个新的部件。这意味着之前的标签页会被彻底销毁,新的标签页会从头开始构建。

      这种做法会导致许多问题:滚动位置会丢失,文本输入框的内容会重置,网络请求可能会再次被触发。总体来说,这种体验会让用户感到不一致,甚至有些令人沮丧。

      理解默认行为

      如果没有任何状态保存机制,切换标签页时的操作过程如下:

      用户选择一个新的标签页
      当前标签页会被从内存中清除
      新的标签页会重新被创建

      Visualizing the Default Behavior

      在任何时候,内存中只会有一个标签页被保留下来,其他所有的标签页都会被丢弃。

      了解IndexedStack的工作原理

      IndexedStack彻底改变了这种行为模式。它不会重新构建所有部件,而是让它们都保持活跃状态,只改变哪个部件是可见的。

      在内部,它会存储所有的子部件,并通过索引来决定哪个部件应该被显示出来。

      下面是一个简单的思维模型,用来说明它的运作方式:

      IndexedStack
         ├── 标签页0
         ├── 标签页1
         ├── 标签页2
         └── 标签页3
      
      只有其中一个标签页是可见的
      所有标签页都保留在内存中

      这意味着,当你切换标签页时,没有任何部件会被销毁,只是界面的显示状态发生了变化而已。

      为什么IndexedStack能提升用户体验

      最直接的好处就是状态能够被保留下来。如果用户在某个标签页中滚动到列表的中间位置,然后切换到另一个标签页,再返回原来的标签页,之前的滚动位置会完好无损地保留下来。

      对于表单输入、动画效果,以及任何原本会被重置的用户界面状态来说,这一机制也同样适用。

      另一个好处是性能更加稳定。由于部件不会被反复重新构建,应用程序就可以避免进行不必要的操作。当标签页中包含复杂的用户界面元素或需要执行耗时较长的操作(比如API调用)时,这一点尤为重要。

      构建任务管理器示例

      为了使这个例子更具实用性,让我们来看一个拥有四个标签页的任务管理器应用程序。这些标签页分别代表“今日任务”、“即将进行的任务”、“已完成的任务”以及“设置”。

      以下是使用IndexedStack实现的完整代码示例:

      import 'package:flutter/material.dart';
      
      void main() {
        runApp(const MyApp());
      }
      
      class MyApp extends StatelessWidget {
        const MyApp({super.key});
      
        @override
        Widget build(BuildContext context) {
          return MaterialApp(
            title: '任务管理器',
            theme: ThemeData(
              primarySwatch: Colors.blue,
            ),
            home: const TaskManagerScreen(),
          );
        }
      }
      
      class TaskManagerScreen extends StatelessWidget {
        const TaskManagerScreen({super.key});
      
        @override
        State createState() => _TaskManagerScreenState();
      }
      
      class _TaskManagerScreenState extends State {
        int _currentIndex = 0;
      
        final List _tabs = [
          TodayTasksTab(),
          UpcomingTasksTab(),
          CompletedTasksTab(),
          SettingsTab(),
        ];
      
        void _onTabTapped(int index) {
          setState(() {
            _currentIndex = index;
          });
        }
      
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(
              title: const Text('任务管理器'),
            ),
            body: IndexedStack(
              index: _currentIndex,
              children: _tabs,
            ),
            bottomNavigationBar: BottomNavigationBar(
              currentIndex: _currentIndex,
              onTap: _onTabTapped,
              items: const [
                BottomNavigationBarItem(
                  icon: Icon'icon_today),
                  label: '今日任务',
                ),
                BottomNavigationBarItem(
                  icon: Icon'icon.upcoming),
                  label: '即将进行的任务',
                ),
                BottomNavigationBarItem(
                  icon: Icon'icon.done),
                  label: '已完成的任务',
                ),
                BottomNavigationBarItem(
                  icon: Icon'icon.settings),
                  label: '设置',
                ),
              ],
            ),
          );
        }
      }
      
      class TodayTasksTab extends StatelessWidget {
        @override
        Widget build(BuildContext context) {
          return ListView.builder(
            itemCount: 50,
            itemBuilder: (context, index) {
              return ListTile(title: Text('今日任务 $index'));
            },
          );
        }
      }
      
      class UpcomingTasksTab extends StatelessWidget {
        @override
        Widget build(BuildContext context) {
          return Center(child: Text('即将进行的任务'));
        }
      }
      
      class CompletedTasksTab extends StatelessWidget {
        @override
        Widget build(BuildContext context) {
          return Center(child: Text('已完成的任务'));
        }
      }
      
      class SettingsTab extends StatelessWidget {
        @override
        Widget build(BuildContext context) {
          return Center(child: Text('设置'));
        }
      }
      

      这个Flutter应用程序首先运行MyApp,从而创建一个包含标题、主题的MaterialApp,并将TaskManagerScreen设置为首页。在这个页面中,一个有状态的组件会管理当前选中的标签页索引,并使用IndexedStack来显示四个标签页中的一个,同时确保所有标签页都保留在内存中。

      BottomNavigationBar允许用户在各个标签页之间切换,而每个标签页实际上都是一个独立的无状态组件,它会自行渲染相应的内容(例如,用于显示今日任务的滚动列表,或用于展示其他内容的简单文本视图)。

      处理每个标签页的独立导航功能

      你会很快遇到这样一个限制:虽然IndexedStack能够保留每个标签页的状态,但它并不会自动为每个标签页分配独立的导航系统。

      在实际应用中,每个标签页通常都需要拥有自己的内部导航机制。例如,在任务管理器中,“今日”标签页可能会跳转到任务详情页面,而“设置”标签页则会跳转到偏好设置页面。这些导航流程不应该互相干扰。

      为了解决这个问题,你可以将IndexedStack与每个标签页对应的Navigator结合使用。

      概念结构

      IndexedStack
         ├── Navigator (标签页0)
         │     ├── 页面A
         │     └── 页面B
         ├── Navigator (标签页1)
         ├── Navigator (标签页2)
         └── Navigator (标签页3)
      

      现在,每个标签页都可以独立地管理自己的导航历史记录。

      实现方式

      class TaskManagerScreen extends StatefulWidget {
        const TaskManagerScreen({super.key});
      
        @override
        State createState() => _TaskManagerScreenState();
      }
      
      class _TaskManagerScreenState extends State {
        int _currentIndex = 0;
      
        final _navigatorKeys = List.generate(
          4,
          (index) => GlobalKey,
        );
      
        void _onTabTapped(int index) {
          if (_currentIndex == index) {
            _navigatorKeys[index]
                .currentState
                ?.popUntil((route) => route.isFirst);
          } else {
            setState(() {
              _currentIndex = index;
            });
          }
        }
      
        Widget _buildNavigator(int index, Widget child) {
          return Navigator(
            key: _navigatorKeys[index],
            onGenerateRoute: (routeSettings) {
              return MaterialPageRoute(
                builder: (_) => child,
              );
            },
          );
        }
      
        @override
        Widget build(BuildContext context) {
          final tabs = [
            _buildNavigator(0, const TodayTasksTab()),
            _buildNavigator(1, const UpcomingTasksTab()),
            _buildNavigator(2, const CompletedTasksTab()),
            _buildNavigator(3, const SettingsTab()),
          ];
      
          return Scaffold(
            body: IndexedStack(
              index: _currentIndex,
              children: tabs,
            ),
            bottomNavigationBar: BottomNavigationBar(
              currentIndex: _currentIndex,
              onTap: _onTabTapped,
              items: const [
                BottomNavigationBarItem'icon: IconIcons.today), label: '今日',
                BottomNavigationBarItem ICON: IconIcons.upcoming), label: '即将进行的任务',
                BottomNavigationBarItem(icon: IconIcons.done), label: '已完成的任务',
                BottomNavigationBarItemICON: IconIcons.settings), label: '设置',
              ],
            ),
          );
        }
      }
      

      TaskManagerScreen的这种实现方式使用了一个具有状态功能的组件来管理标签页导航:它通过维护当前的标签页索引,并为每个标签页使用唯一的GlobalKey来创建一个独立的Navigator》,从而确保每个标签页都能拥有自己的独立导航栈。

      _onTabTapped方法会在用户点击标签页时切换标签页,或者如果再次点击当前标签页,则将其导航栈重置到起始状态。IndexedStack能够保证所有标签页的导航组件都保留在内存中,而只有被选中的标签页才会显示出来,这样一来,用户的浏览状态就能得到保留,标签页之间的切换也会非常流畅。

      这种实现方式能解决什么问题

      现在,每个标签页都像一个独立的小应用一样运行。在一个标签页内进行操作不会影响到其他标签页。当用户切换标签页后再返回时,他们会回到上次离开的位置,包括那些嵌套的界面。

      这种设计模式被广泛应用于银行应用、社交平台以及数据面板等实际生产环境中。

      IndexedStack与状态管理结合使用

      开发者们常常会犯一个错误,那就是将IndexedStack当作一种完整的状态管理解决方案来使用。但实际上并非如此。

      IndexedStack确实能够保留组件的状态,但它并不能负责处理业务逻辑或共享数据。

      对于那些需要扩展性的应用程序来说,仍然应该使用像BLoC、Provider或者Riverpod这样的专业状态管理工具。

      使用BLoC的示例

      每个标签页都可以独立接收自己的数据流,同时这些数据也会被保留在内存中。

      class TodayTasksTab extends StatelessWidget {
        const TodayTasksTab({super.key});
      
        @override
        Widget build(BuildContext context) {
          return StreamBuilder<List<>String>>>(
            stream: getTasksStream(),
            builder: (context, snapshot) {
              if (!snapshot.hasData) {
                return const Center(child: CircularProgressIndicator());
              }
      
              final tasks = snapshot.data!;
      
              return ListView.builder(
                itemCount: tasks.length,
                itemBuilder: (context, index) {
                  return ListTile(title: Text(tasks[index]));
                },
              );
            },
          );
        }
      }
      

      由于标签页不会被重新创建,因此数据流的订阅状态也会保持稳定,不会出现不必要的重启情况。

      性能方面的考虑

      在这里,开发者需要谨慎行事。IndexedStack会保留所有组件的状态,这意味着每个标签页都会增加内存占用量。

      内部工作原理

      所有的子组件都只会被构建一次,
      之后就会一直保持挂载状态,
      只有可见性会发生变化。

      这种设计在提升交互体验方面非常有效,但在占用内存方面却不一定理想。

      何时会出现问题

      如果每个标签页都包含大量的组件,比如长列表、图片或者复杂的动画效果,那么内存消耗量就会显著增加。

      在极端情况下,这可能会导致低端设备出现帧率下降甚至应用程序崩溃。

      实际应用策略

      对于数量较少的核心标签页,可以使用IndexedStack。通常三到五个标签页是比较合适的范围。

      如果你发现需要添加更多的页面,那么应该重新考虑导航结构,而不是强行将所有内容都纳入同一个栈中。

      常见错误

      一个常见的误解是认为IndexedStack会延迟组件的生成。实际上并非如此,所有的子组件都会立即被创建出来。

      另一个错误是将IndexedStack与那些需要重新构建组件的逻辑混合使用。由于组件会被保留下来,因此某些生命周期方法的行为可能会发生异常。

      开发者有时还会忘记,使用IndexedStack会导致内存被持续占用,从而在后续引发性能问题(正如我们刚才讨论的那样)。

      Navigator → 负责控制页面切换 IndexedStack → 负责控制持久组件的可见性 状态管理 → 负责管理数据和逻辑流程

一旦你把这些功能区分开来,你的应用程序架构就会变得更加清晰,也更容易进行扩展。

可视化对比

要想真正理解这两种方式之间的区别,就需要对它们进行比较。

不使用IndexedStack时:

切换标签页
→ 当前页面被销毁
→ 新页面被重新创建
→ 数据状态丢失

使用IndexedStack时:

切换标签页
→ 所有页面都保持存活状态
→ 只有相关组件的可见性会发生变化
→ 数据状态保持不变

重要的权衡因素

需要记住的是,IndexedStack》会同时将所有子组件保留在内存中。

对于数量较少的标签页来说,这种设计通常没有问题;但如果每个标签页都包含大量的组件或庞大的数据量,那么内存使用量就会显著增加。

因此,选择哪种方式并不只是为了方便性,而是要根据具体的应用场景来挑选最适合的工具。

如果你的标签页体积较小且需要保留状态信息,IndexedStack是一个不错的选择;但如果标签页内容较多且很少被访问,那么重新构建这些组件可能反而更合适。

总结来说:

  • 当每个标签页都有独立的状态,并且用户需要频繁地在它们之间切换时,IndexedStack是非常理想的选择。它在仪表盘、任务管理器、金融应用以及社交应用中尤为有用,因为这些场景非常强调连续性。

  • 如果你的应用程序包含大量的页面,或者每个页面都会占用大量内存,那么让所有页面都保持存活状态可能会导致效率低下。在这种情况下,使用结合了适当状态管理机制的导航系统(如BLoC、Provider或Riverpod)可能会是更好的解决方案。

结论

IndexedStack表面上看很简单,但它的真正优势在于那些用户体验至关重要的复杂应用中。它能够避免不必要的重新构建操作,保持用户界面的状态不变,从而让交互过程更加流畅。

不过请务必谨慎使用它——它并不能替代导航系统或状态管理机制,而只是作为一种补充工具。

如果你能将其与嵌套导航结构及恰当的状态管理策略正确结合在一起,那么你就能构建出一种对用户来说使用起来非常顺滑、且随着应用程序的发展依然易于维护的架构。

]]>
如何利用浏览器使用数据以及Claude API来构建一个本地SEO审计工具 http://www.cheeli.com.cn/articles/how-to-build-a-local-seo-audit-agent-with-browser-use-and-claude-api/ Wed, 01 Apr 2026 20:20:47 +0000 http://www.cheeli.com.cn/?p=21020 Read More]]> 每个数字营销机构都有人负责这样的工作:打开电子表格,访问每一个客户的网站地址,检查标题标签、元描述以及H1标签,记录下所有失效的链接,然后将这些信息整理成报告。下周再重复这个过程。

这种工作具有确定性,任何员工都能完成它。

在本教程中,你将使用Python、Browser Use以及Claude API从零开始构建一个用于进行本地SEO审计的工具。该工具会在可视化的浏览器窗口中访问真实的网页,利用Claude提取相关的SEO数据,异步检查失效链接,在遇到特殊情况时会暂停执行并等待人工干预,最后生成结构清晰的报告——即使中途中断,也可以继续后续操作。

完成制作后,你将拥有一个可以用于处理任意URL列表的工具。每个URL的运行成本不到0.01美元。

你将构建什么

这是一个由七个模块组成的Python工具,它可以:

  • 从CSV文件中读取URL列表

  • 在真实的Chromium浏览器中访问每一个URL(而不是使用无头爬虫程序)

  • 通过Claude API提取标题、元描述、H1标签以及规范链接标签

  • 使用httpx异步检查失效链接

  • 检测特殊情况(如404错误、需要登录才能访问的页面、重定向等),并在必要时暂停执行以等待人工输入

  • 将处理结果逐步写入report.json文件中——即使中途中断,也可以继续后续操作

  • 在任务完成后生成一份用通俗语言编写的report-summary.txt报告

完整的代码可以在GitHub上找到,地址为:dannwaneri/seo-agent

先决条件

  • Python 3.11或更高版本

  • Anthropic API密钥(可在console.anthropic.com获取)

  • Windows、macOS或Linux操作系统

  • 对Python及命令行有一定的了解

目录

  1. 为什么使用浏览器而不是爬虫程序

  2. 项目结构

  3. 准备工作

  4. 模块1:状态管理

  5. 模块2:浏览器集成

  6. 模块3:Claude数据提取层

  7. 模块4:失效链接检查器

  8. 模块5:人工干预机制

  9. 模块6:报告生成器

  10. 模块7:主循环流程

  11. 运行该工具

  12. 为机构使用而进行调度安排

  13. 结果展示方式

为什么使用浏览器而不是爬虫

进行SEO审计的标准方法是使用requests获取页面的HTML内容,然后利用BeautifulSoup对其进行解析。这种方法适用于静态页面,但对于由JavaScript生成的页面来说则无法正常工作;它还会遗漏动态插入的元标签,而在需要身份验证的页面上更是完全无法使用。

“Browser Use”采用了不同的方法:它控制一个真实的Chromium浏览器,在JavaScript执行完毕后再读取DOM结构,并通过Playwright的工具树来呈现页面内容。这样,该工具就能像人类一样看到页面的实际显示效果。

实际应用中的区别在于:基于requests

另一个值得注意的区别是:“Browser Use”能够以语义化的方式解析页面内容。例如,当某个按钮的CSS类从btn-primarybutton-main

项目结构

seo-agent/
├── index.py          # 主要审计流程
├── browser.py        # 使用浏览器进行页面解析的模块
├── extractor.py      # 用于与Claude API交互的数据提取层
├── linkchecker.py    # 异步检测失效链接的工具
├── hitl.py           | 实时暂停机制
├── reporter.py       | 报告生成工具
├── state.py          | 状态保存机制(可在中断后继续执行)
├── input.csv         | 需要审计的URL列表
├── requirements.txt
├── .env.example
└── .gitignore

安装与配置

首先创建一个项目文件夹,然后安装所需的依赖项:

mkdir seo-agent && cd seo-agent
pip install browser-use anthropic playwright httpx
playwright install chromium

接下来创建input.csv文件,将需要审计的URL列表保存其中:

url
https://example.com
https://example.com/about
https://example.com/contact

然后创建.env.example文件,并设置ANTHROPIC_API_KEY环境变量:

ANTHROPIC_API_KEY=your-key-here

在运行程序之前,请确保已将API密钥设置为相应的环境变量:

# macOS/Linux
export ANTHROPIC_API KEY="sk-ant-..."
# Windows PowerShell
$env:ANTHROPIC_API_KEY = "sk-ant-..."

最后创建.gitignore文件,指定哪些文件不需要被版本控制工具跟踪:

state.json
report.json
report-summary.txt
.env
__pycache__/
*.pyc

模块1:状态管理

该工具需要记录已经审计过的URL列表。如果审计过程被中断——无论是由于停电、键盘按键被按下还是网络故障——它都应该从上次停止的地方继续执行,而不是重新开始。

state.py文件通过一个简单的JSON文件来实现这一功能:

import json
import os

STATE_FILE = os.path.join(os.path.dirname(__file__), "state.json")

_DEFAULT_STATE = {"audited": [], "pending": [], "needs_human": []}

def load_state() -> dict:
    if not os.path.exists(STATE_FILE):
        save_state(_DEFAULT_STATE.copy())
    with open(STATE_FILE, encoding="utf-8") as f:
        return json.load(f)

def save_state(state: dict) -> None:
    with open(STATE_FILE, "w", encoding="utf-8") as f:
        json.dump(state, f, indent=2)

def is_audited(url: str) -> bool:
    return url in load_state()["audited"]

def mark_audited(url: str) -> None:
    state = load_state()
    if url not in state["audited"]:
        state["audited"].append(url)
    save_state(state)

def add_to_needs_human(url: str) -> None:
    state = load_state()
    if url not in state["needs_human":
        state["needs_human"].append(url)
    save_state(state)

这一设计是经过深思熟虑的:mark_audited()函数会在处理完某个URL并将其写入报告之后立即被调用。如果代理程序在运行过程中崩溃,那么它最多只会丢失对那个URL的处理结果。

模块2:浏览器集成

browser.py负责实际的页面导航操作。它直接使用Playwright来打开一个可视化的Chromium窗口,导航到指定的URL,捕获HTTP状态码及重定向信息,并从DOM中提取原始的SEO数据。

一些关键的设计决策包括:

使用可视化浏览器,而非无头浏览器。headless=False设置为True,这样就可以观察到代理程序的实际运行过程。这对于演示和调试来说非常重要。

通过响应监听器来捕获状态信息。当收到4xx或5xx类型的响应时,Playwright会抛出异常,但on("response", ...)处理函数会在异常发生之前被执行,因此我们可以在那里获取到状态码信息。

每次访问之间会间隔2秒钟。这样就可以避免触发代理程序所在客户端的速率限制机制或机器人检测系统。

以下是核心的页面导航功能代码:

import asyncio
import sys
import time
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout

TIMEOUT = 20_000  # 20秒


def fetch_page(url: str) -> dict:
    result = {
        "final_url": url,
        "status_code": None,
        "title": None,
        "meta_description": None,
        "h1s": [],
        "canonical": None,
        "raw_links": [],
    }

    first_status = {"code": None}

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        page = browser.new_page()

        def on_response(response):
            if first_status["code"] is None:
                first_status["code"] = response.status

        page.on("response", on_response)

        try:
            page.goto(url, wait_until="domcontentloaded", timeout=TIMEOUT)
            result["status_code"] = first_status["code"] or 200
            result["final_url"] = page.url

            # 从DOM中提取SEO数据
            result["title"] = page.title() or None
            result["meta_description"] = page.evaluate(
                "() => { const m = document.querySelector('meta[name=\"description\"]'); "
                "return m ? m.getAttribute('content') : null; }"
            )
            result["h1s"] = page.evaluate(
                "() => Array.from(document.querySelectorAll('h1')).map(h => h.innerText.trim())"
            )
            result["canonical"] = page.evaluate(
                "() => { const c = document.querySelector('link[rel=\"canonical\"]'); "
                "return c ? c.getAttribute('href') : null; }"
            )
            result["raw_links"] = page.evaluate(
                "() => Array.from(document.querySelectorAll('a[href]'))"
                ".map(a => a.href).filter(Boolean).slice(0, 100)"
            )

        except PlaywrightTimeout:
            result["status_code"] = first_status["code"] or 408
        except Exception as exc:
            print(f"[browser] 错误:{exc}", file=sys.stderr)
            result["status_code"] = first_status["code"]
        finally:
            browser.close()

    time.sleep(2)
    return result

有几点需要注意:

raw_links的限制为100个链接,这是有意为之。DEV.to的个人主页页面上包含数百个链接——在进行失效链接检测时,并不需要使用所有这些链接。

wait_until="domcontentloaded"这个设置比networkidle更快,而且对于提取元标签来说已经足够了。由JavaScript渲染的内容需要等到DOM结构准备好才能被处理,而不是等到所有的网络请求都完成。

模块3:Claude提取层

extractor.py会从browser.py中获取原始页面数据,然后调用Claude来生成结构化的SEO审计结果。

大多数教程在这个环节都会出错。它们要么在Python中编写复杂的解析逻辑(这种做法很不可靠),要么让Claude返回非结构化的文本,然后再尝试解析这些文本(同样不可靠)。正确的做法是:为Claude提供一个严格的JSON格式规范,并要求它只返回符合这个规范的结果。

正是这种精确的提示设计才使得这一过程能够可靠地运行:

import json
import os
import sys
from datetime import datetime, timezone
import anthropic

MODEL = "claude-sonnet-4-20250514"
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))


def _strip_fences(text: str) -> str:
    """从Claude的回复中去除多余的markdown格式标签。"""
    text = text.strip()
    if text.startswith("```"):
        lines = text.splitlines()
        # 删除开头的标记
        lines = lines[1:] if lines[0].startswith("```") else lines
        # 删除结尾的标记
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()
    return text


def extract(snapshot: dict) -> dict:
    if not os.environ.get("ANTHROPIC_API_KEY"):
        raise OSError("ANTHROPIC_API_KEY未设置。")

    prompt = f"""你是一名SEO审计员。请分析这个页面数据,并仅返回一个JSON对象。
不要包含任何散文性内容,也不要添加解释或markdown格式标签。只返回原始的JSON数据。

页面信息:
- URL: {snapshot.get('final_url')}
- 状态码: {snapshot.get('status_code')]
- 标题: {snapshot.get('title'}
- 元描述: {snapshot.get('meta_description'}
- H1标题: {snapshot.get('h1s'}
- 规范链接: {snapshot.get('canonical')}

返回以下格式的JSON对象:
{{
  "url": "string",
  "final_url": "string",
  "status_code": number,
  "title": {{"value": "string or null", "length": number, "status": "PASS or FAIL"},
  "description": {{"value": "string or null", "length": number, "status": "PASS or FAIL"},
  "h1": {{"count": number, "value": "string or null", "status": "PASS or FAIL"},
  "canonical": {{"value": "string or null", "status": "PASS or FAIL"},
  "flags": ["array of strings describing specific issues"],
  "human_review": false,
  "audited_at": "ISO timestamp"
}}

PASS/FAIL规则:
- 标题:如果为空或长度超过60个字符,则视为失败
- 描述:如果为空或长度超过160个字符,则视为失败
- H1标题:如果数量为0(缺失)或数量大于1(重复出现),则视为失败
- 规范链接:如果为空,则视为失败
- flags:列出所有出错的字段,并附上详细的说明
- audited_at:使用当前UTC时间的ISO 8601格式"

    response = client.messages.create(
        model=MODEL,
        max_tokens=1000,
        messages=[{"role": "user", "content": prompt}],
    )

    raw = response.content[0].text
    clean = _strip_fences(raw)

    try:
        return json.loads(clean)
    except json.JSONDecodeError as exc:
        print(f"[extractor] JSON解析错误:{exc}", file=sys.stderr)
        return _error_result(snapshot, str(exc))


def _error_result.snapshot: dict, reason: str) -> dict:
    return {
        "url": snapshot.get("final_url", ""),
        "final_url": snapshot.get("final_url", ""),
        "status_code": snapshot.get("status_code"),
        "title": {"value": None, "length": 0, "status": "ERROR"},
        "description": {"value": None, "length": 0, "status": "ERROR"},
        "h1": {"count": 0, "value": None, "status": "ERROR"},
        "canonical": {"value": None, "status": "ERROR"},
        "flags": [f"提取错误:{reason}"],
        "human_review": True,
        "audited_at": datetime.now(timezone.utc).isoformat(),
    }

有两点确保了这一功能在实际生产环境中的可靠性:

首先,`_strip_fences()`函数能够处理这样一种情况:尽管被明确要求不要这样做,但Claude仍然会将其响应用`json`格式的标签括起来。这种情况在Sonnet中偶尔会发生,如果不加以处理,就会导致`json.loads()`函数出现错误。

其次,`_error_result()`函数能够确保代理程序在接收到Claude返回的错误响应时不会崩溃——它会记录错误信息,并将相关URL标记出来供人工审核,然后继续处理下一个URL。

成本:Claude Sonnet 4的服务费用为:每百万输入字符3美元,每百万输出字符15美元。一般来说,一个页面的快照大约需要500个输入字符;而结构化的JSON响应则大约需要300个输出字符。因此,对于一个包含20个URL的审计任务来说,总成本大概为0.006美元/URL,也就是0.12美元。

linkchecker.py会从浏览器快照中获取`raw_links`列表,然后使用异步HEAD请求来检查这些链接是否有效。

在设计上我们做了以下几项选择:

  • 仅检测同一域名的链接。

    如果检查页面上的所有外部链接,将会花费很长时间,而这也不是代理客户所需要的。因此,我们只检查与被审计页面属于同一域名的链接。

  • 使用HEAD请求而非GET请求。

    HEAD请求速度更快,占用的带宽也更少,完全足以用来检测链接的状态码。

  • 每次最多检测50个链接。

    像DEV.to这样的网站,其文章列表中通常会包含数百个内部链接。如果全部检查这些链接,将会严重影响程序的运行效率。

  • 通过asyncio同时处理多个请求。

    所有链接都会被并行检测,而不是依次处理。

import asyncio
import logging
from urllib.parse import urlparse
import httpx

CAP = 50
TIMEOUT = 5.0
logger = logging.getLogger(__name__)

def _same_domain(link: str, final_url: str) -> bool:
if not link:
return False
lower = link.strip().lower()
if lower.startswith("#

or "mailto:"
or "javascript:"
or "tel:"
or "data:"):
return False
try:
page_host = urlparse(final_url).netloc.lower()
parsed = urlparse(link)
return parsedscheme in ("http", "https") and parsed.netloc.lower() == page_host
except Exception:
return False

async def _check_link(client: httpx.AsyncClient, url: str) -> tuple[str, bool]:
try:
resp = await client.head(url, follow_redirects=True, timeout=TIMEOUT)
return url, resp.status_code != 200
except Exception:
return url, True # 表示请求超时或连接失败,因此链接无效

async def _run_checks(links: list[str]) -> list[str]:
async with httpx.AsyncClient() as client:
results = await asyncio.gather(*[_check_link(client, url) for url in links])
return [url for url, broken in results if broken]

def check_links(raw_links: list[str], final_url: str) -> dict:
same_domain = [l for l in raw_links if _same_domain(l, final_url)]

capped = len(same_domain) > CAP
if capped:
logger.warning("页面中包含%d个同一域名的链接,但最多只能检测%d个。",
len(same_domain), CAP)
same_domain = same_domain[:CAP]

broken = asyncio.run(_run_checks(same_domain))

return {
"broken": broken,
"count": len(broken),
"status": "FAIL" if broken else "PASS",
"capped": capped,
}

模块5:人工干预机制

大多数自动化教程都会跳过这一部分。当程序遇到登录障碍时会发生什么?当页面返回403错误代码时怎么办?或者当链接跳转到“订阅以继续阅读”页面时又该如何处理?

大多数脚本在这种情况下要么会崩溃,要么会默默地忽略这些异常。但在实际应用中,这两种情况都是不可接受的。

hitl.py通过两个函数来解决这些问题:一个函数用于判断是否需要暂停操作,另一个函数则负责执行暂停操作本身。

from state import add_to_needs_human

LOGIN_KEYWORDS = {"login", "sign in", "sign-in", "access denied", "log in", "unauthorized"}
REDIRECT_CODES = {301, 302, 307, 308}

def should_pause(snapshot: dict) -> bool:
    code = snapshot.get("status_code")

    # 如果导航完全失败
    if code is None:
        return True

    # 如果状态码不是200,且不属于重定向代码
    if code != 200 and code not in REDIRECT_CODES:
        return True

    # 检查标题或H1标签中是否包含登录相关关键词
    title = (snapshot.get("title") or "").lower()
    h1s = [h.lower() for h in (snapshot.get("h1s") or [])]

    if any(kw in title for kw in LOGIN_KEYWORDS):
        return True
    if any(kw in h1 for kw in LOGIN_keywords for h1 in h1s):
        return True

    return False


def pause_reason(snapshot: dict) -> str:
    code = snapshot.get("status_code")
    if code is None:
        return "导航失败(状态码未知)"
    if code != 200 and code not in REDIRECT_CODES:
        return f"出现异常状态码:{code}"
    return "可能遇到了登录障碍"

should_pause()函数可以检测四种情况:导航失败、出现非预期的HTTP状态码、标题中包含登录相关关键词,以及H1标签中包含这些关键词。其中,对标题的检查能够有效识别那些虽然返回200状态码但实际上无法访问的页面。

--auto模式下(用于定时执行任务),主循环会直接跳过pause_and_prompt()函数的调用,而是将相关信息记录到needs_human[]数组中,然后继续执行后续操作。

模块6:报告生成工具

reporter.py会逐个记录处理结果。这一点非常重要:因为结果是在每次审核完一个URL后立即被记录下来的,而不是在所有操作完成后才一次性生成。这样一来,如果任务中途被中断,之前完成的工作也不会丢失。

import json import os from datetime import datetime, timezone REPORT_JSON = os.path.join(os.path.dirname(__file__), "report.json") REPORTTXT = os.path.join(os.path.dirname(__file__), "report-summary.txt") def _load_report() -> list: if not os.path.exists(REPORT_json): return [] with open(REPORT_JSON, encoding="utf-8") as f: return json.load(f) def write_result(result: dict) -> None: """在 report.json 文件中添加或更新相关数据。""" entries = _load_report() url = result.get("url", "") # 如果该 URL 已经存在于文件中,则更新相应的记录(支持重试机制) for i, entry in enumerate(entries): if entry.get("url") == url: entries[i] = result break else: entries.append(result) with open(REPORT_JSON, "w", encoding="utf-8") as f: json.dumpentries, f, indent=2, ensure_ascii=False) def _is_overall_pass(result: dict) -> bool: fields = ["title", "description", "h1", "canonical"] for field in fields: if result.get(field, {}).get("status") not in ("PASS",): return False if result.get("broken_links", "").get("status") == "FAIL": return False return True def write_summary() -> None: entries = _load_report() passed = sum(1 for e in entries if _is_overall_pass(e)) lines = [] for entry in entries: overall = "PASS" if _is_overall_pass(entry) else "FAIL" failed_fields = [ f for f in ["title", "description", "h1", "canonical", "broken_links"] if entry.get(f, "").get("status") == "FAIL" ] suffix = f" [{', '.join(failed_fields)}]" if failed_fields else "" lines.append(f"{entry.get('url', 'unknown'):<60} | {overall}{suffix}") lines.append("") lines.append(f"{passed}/{len(entries)} 个 URL 通过了检测") with open(REPORTTXT, "w", encoding="utf-8") as f: f.write("\n".join(lines))

write_result()中的去重机制能够妥善处理重试操作。如果在人工审核并完成身份验证后再次尝试访问某个URL,系统会用新的审核结果替换原有的记录,而不会创建重复的条目。

模块7:主循环

index.py负责将所有组件连接在一起。它读取URL列表,加载状态信息,跳过已经完成审核的URL,然后启动审核流程。

import csv
import os
import sys
import time
import argparse

from state import load_state, is_audited, mark_audited, add_to_needs_human
from browser import fetch_page
from extractor import extract
from linkchecker import check_links
from hitl import should_pause, pause_reason, pause_and_prompt
from reporter import write_result, write_summary

INPUT_csv = os.path.join(os.path.dirname(__file__), "input.csv")

def read_urls(path: str) -> list[str]:
    with open(path, newline="", encoding="utf-8") as f:
        return 
.strip() for row in csv.DictReader(f) if row.get("url", "").strip()]
def run(auto: bool = False): if not os.environ.get("ANTHROPIC_API_KEY"): print("错误:未设置ANTHROPIC_API KEY环境变量。") sys.exit(1) urls = read_urls(INPUT_csv) pending = [u for u in urls if not is_audited(u)] print(f"开始审核:还有{len(pending)}个URL待审核,已有{len(urls) - len(pending)}个URL完成审核。\n") total = lenurls) try: for i, url in enumerate(pending, start=1): position = urls.index(url) + 1 print(f"[{position}/{total}] {url}", end=" -> ", flush=True) # 打开浏览器访问该URL snapshot = fetch_page(url) # 进行人工审核 if should_pause(snapshot): reason = pause_reason(snapshot) if auto: print(f"自动跳过({reason})") add_to_needs_human(url) mark_audited(url) continue action = pause_and_prompt(url, reason) if action == "quit": print("退出审核流程。") break elif action == "skip": add_to_needs_human(url) mark_audited(url) continue # 如果选择“retry”,则重新尝试访问该URL snapshot = fetch_page(url) # 使用Claude工具提取信息 result = extract(snapshot) # 检查是否存在失效的链接 links = check_links(snapshot.get("raw_links", []), snapshot.get("final_url", url)) result["broken_links"] = links # 立即写入审核结果 write_result(result) mark_audited(url) overall = "PASS" if all( result.get(f, "").get("status") == "PASS" for f in ["title", "description", "h1", "canonical"] ) and links["status"] == "PASS" else "FAIL" print(overall) except KeyboardInterrupt: print("\n审核过程被中断。已保存进度,重新运行即可继续。") return write_summary() passed = sum( 1 for e in [r for r in []] if all(e.get(f, "").get("status") == "PASS" for f in ["title", "description", "h1", "canonical"]) ) print(f"\n审核完成。报告已保存至report.json和report-summary.txt文件中。") if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--auto", action="store_true", help="自动跳过需要人工审核的URL") args = parser.parse_args() run(auto=args.auto)

KeyboardInterrupt处理程序就是用于恢复程序运行的机制。当你按下Ctrl+C时,该处理程序会输出一条消息并干净地退出程序。由于对于每个URL来说,在write_result()方法执行之后都会调用mark_audited()方法,因此下次运行程序时会跳过已经处理过的所有内容。

运行代理程序

交互模式(在遇到特殊情况时会暂停程序运行):

python index.py

自动模式(会跳过特殊情况,并将未处理的URL添加到needs_human[]列表中):

python index.py --auto

当程序运行时,你会看到每个URL对应的浏览器窗口会被打开,同时终端也会显示进度信息:

开始审计:还有7个URL未处理,0个已经完成。
[1/7] https://example.com -> 通过
[2/7] https://example.com/about -> 失败
[3/7] https://example.com/contact -> 被自动跳过(返回的状态码为404)
…
审计已完成。报告文件已保存为report.json和report-summary.txt

如果程序运行过程中被中断,可以重新运行它:

python index.py --auto
# 开始审计:还有4个URL未处理,3个已经完成。

为机构使用安排调度任务

对于需要每周定期执行的审计任务,可以创建一个批处理文件,然后通过Windows任务计划程序来安排执行。

创建run-audit.bat文件:

@echo off
set ANTHROPIC_API_KEY=你的API密钥
cd /d C:\Users\你的用户名\Desktop\seo-agent
python index.py --auto

在Windows任务计划程序中,按照以下步骤操作:

  1. 创建一个新的基本任务。

  2. 将触发条件设置为“每周一上午7点”。

  3. 将动作设置为“启动一个程序”。

  4. 选择run-audit.bat文件作为要执行的程序。

周一早上查看report-summary.txt文件。其中那些被标记为needs_human[]的URL需要人工审核——比如那些需要输入用户名或密码才能访问的页面,或者返回了异常状态码的页面。

对于macOS/Linux系统,可以使用cron来安排任务:

# 每周一上午7点运行
0 7 * * 1 cd /path/to/seo-agent && ANTHROPIC_API_KEY=你的API密钥 python index.py --auto

审计结果是什么样的

我使用这个代理程序对我的在Hashnode、freeCodeCamp和DEV.to上发布的7个页面进行了检测,结果所有这些页面都失败了。

https://hashnode.com/@dannwaneri                    | 失败 [标题]
https://freecodecamp.org/news/claude-code-skill     | 失败 [描述]
https://freecodecamp.org/news/stop-letting-ai-guess | 失败 [描述]
https://freecodecamp.org/news/rag-system-handbook   | 失败 [标题, 描述]
https://freecodecamp.org/news/author/dannwaneri     | 失败 [描述]
https://dev.to/dannwaneri/gatekeeping-panic         | 失败 [标题]
https://dev.to/dannwaneri/production-rag-system     | 失败 [标题]

0/7个URL通过审核

freeCodeCamp在描述文章时存在的问题,部分是由于平台本身的限制所致——freeCodeCamp使用的模板有时会截断或省略文章列表页面的元描述信息。而DEV.to在标题处理方面存在的问题则出在我自己身上,那些被用作标题的文章,其</code>标签中的字符长度往往超过了60个。</p> <p>需要说明的是,这个60个字符的长度限制其实只是一个显示上的限制,并不会影响文章的排名。谷歌会收录所有长度的标题。设定这一限制主要是为了确保在桌面端搜索结果中,标题能够完整显示而不会被截断。如果标题超过60个字符,虽然仍然会被展示出来,但可能会被截断,从而影响用户的点击率。系统标记这些标题只是为了提醒开发者注意显示上的问题,并不意味着这些标题违反了排名规则。</p> <h2 id="heading-next-steps">后续步骤</h2> <p>目前这个工具已经能够完成基本的SEO审计工作流程。不过还可以进行一些扩展功能,例如:</p> <ul> <li> <p><strong>性能指标检测</strong> — 可以为每个URL添加Lighthouse或PageSpeed Insights的API调用。</p> </li> <li> <p><strong>结构化数据验证</strong> — 检查页面中是否使用了JSON-LD格式进行数据标记,并对其进行验证。</p> </li> <li> <p><strong>邮件发送功能</strong> — 审计完成后,可以通过SMTP发送<code>report-summary.txt</code>文件。</p> </li> <li> <p><strong>多客户支持</strong> — 可以为不同的客户分别准备<code>input.csv</code>文件,并生成不同的报告目录。</p> </li> <p>包含所有七个模块的完整代码可以在<a href="https://github.com/dannwaneri/seo-agent">dannwaneri/seo-agent</a>这个链接找到。你可以克隆这个代码,添加你自己的URL地址,然后运行它。</p> <p><em>如果你觉得这篇文章有用,我还会在<em>DEV.to/@dannwaneri</em><em>这个平台上撰写更多关于如何为开发人员和机构配置AI工具的文章。</em><em>DEV.to上还有关于这个工具设计理念的详细介绍,包括为什么使用HITL算法、为什么优先选择浏览器数据而不是爬虫数据,以及审计结果对你自己发布的文章意味着什么。</em></p> ]]></content:encoded> </item> </channel> </rss>