阅读视图

发现新文章,点击刷新页面。

Nextjs ISR 企业落地实战

背景

Nextjs 项目本来使用的是 SSG 来渲染门户网站的 blog 的,目录如下:

image.png

这样技术实现是简单,但是维护成本比较高。运营和销售同学写完营销文章后,需要推送给研发,由研发录入到 git 仓库中,并且执行一遍发布流程。这样做,没有办法将文章撰写作为一个独立的营销任务,必须借助技术发布。而且还有个问题,blog 越来越多,放在仓库里,会导致仓库大小越来越大:

image.png

需求与技术方案设计

于是我们就设计了一个这样的内部需求:

维护一个内部的 blog 发布平台,运营录入后点击发布,同时通知 Nextjs 项目触发更新文章。

技术方案:

  • 内部 blog 发布平台使用 antd + go 搭建,负责录入文章到数据库,并提供公网接口获取
  • Nextjs 改造为通过接口获取动态数据,设置缓存来优化访问;并暴露 API,内部 blog 发布平台触发更新后清除缓存,用户下次访问就回去拉取最新的数据源。

初步实现

内部 blog 发布平台

没啥说的,普通的后台管理系统,如图

image.png

md 编辑器就使用掘金的 bytemd

image.png

用户录入后,存入到数据库中,并暴露接口来获取。

Nextjs 项目改造

页面配置:

export const dynamic = 'auto'; // 允许页面缓存
export const dynamicParams = true;
export const revalidate = 259200; // 页面缓存 3 天(与 fetch 缓存时间一致)

将读取静态目录换为通过接口(自己开发 API 提供数据源)获取:

// SSR 页面
const postData = {
  Action: "GetPublishedArticleList",
  Lang: lng,
};

const startTime = Date.now();
const res = await fetch(blogApiUrl, {
  method: "POST",
  headers: {
    'remote_user': 'admin',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(postData),
  // 设置缓存:有效期3天(259200秒),使用标签以便外部API可以清除缓存
  next: {
    revalidate: 259200, // 3天
    tags: [`blog-list-${lng}`],
  },
});

// 拿到数据后处理
if (res.ok) {
    const response = await res.json();
    const count = response?.Data?.Total || 0;
    ...
}

...

然后暴露 API,负责清除 fetch 缓存:

image.png

该 API 要记得配置 cors 跨域和来源 ip 和频次限制。

此外,需要配置 api 请求拦截,避免被中间件等影响造成请求不到地址:

async headers() {
    return [
      {
        // 排除 /api 路径,让 API 路由自己处理 CORS
        source: "/((?!api).)*",
        headers: [
          {
            key: "Access-Control-Allow-Origin",
            value: "*", // Set your origin
          },
          {
            key: "Access-Control-Allow-Methods",
            value: "GET, POST, PUT, DELETE, OPTIONS",
          },
          {
            key: "Access-Control-Allow-Headers",
            value: "Content-Type, Authorization",
          },
        ],
      },
    ];
  },

ISR 工作流程

首次访问:

  • 服务端渲染(SSR), fetch 缓存
  • 生成 HTML 并缓存
  • 返回给用户

后续访问(3 天内):

  • 直接返回缓存的 HTML, 不重新渲染, 响应快

3 天后:

  • 第一个请求触发后台重新渲染
  • 更新全部缓存
  • 后续请求使用新缓存

调用 /api/revalidate-blog:

  • 立即清除缓存
  • 下次访问重新渲染

落地演示

新建一篇文章,点击发布,更新状态:

image.png

调用 revalidate API 触发缓存更新:

image.png

线上刷新查看:

image.png

成功!!

从 Next.js 完全迁移到 vinext 的实战踩坑指南

一次真实项目从 Next.js (Cloudflare Pages) 迁移到 vinext (Cloudflare Workers) 的全过程记录,涵盖构建、部署、运行时、国际化、认证等 16+ 个坑位及其解决方案。

项目简介

本文基于开源项目 edge-next-starter 的真实迁移经验撰写。

edge-next-starter 是一个面向 Cloudflare 全栈开发的 Next.js 生产级启动模板,集成了 D1 数据库、R2 对象存储、KV 缓存、better-auth 认证、next-intl 国际化、Stripe 支付等企业级能力,开箱即用。项目采用 Repository 模式 + Edge Runtime 架构设计,所有代码均运行在 Cloudflare Workers 全球边缘网络上。

GitHub: github.com/TangSY/edge… ⭐ 欢迎 Star

背景与动机

2026 年 2 月 24 日,Cloudflare 发布了一篇震动 Web 开发社区的博客:How we rebuilt Next.js with AI in one week。一名 Cloudflare 工程师 Steve Faulkner 使用 Anthropic 的 Claude AI 模型,仅花费约 1100 美元的 token 费用,在一周内从零重建了 Next.js 94% 的 API。产物就是 vinext(发音 "vee-next")—— 一个基于 Vite 构建的 Next.js 替代实现,专门针对 Cloudflare Workers 优化。

Cloudflare CTO Dane Knecht 称之为「Next.js 的解放日」。项目绝大部分代码由 AI 编写,人类负责架构方向、优先级和设计决策。

vinext 相比传统的 @cloudflare/next-on-pages 或 OpenNext 方案,有以下显著优势:

  • 原生 Workers 部署:不再需要逆向工程 Next.js 的构建输出,一条命令直接部署
  • Vite 构建:取代 Turbopack/webpack,生产构建速度最高提升 4 倍
  • 更小的 bundle:客户端包体积最高缩小 57%
  • 开发环境一致性vite dev 直接在 Workers 运行时中运行,可以测试 D1、KV、Durable Objects 等平台 API
  • RSC 原生支持:完整的 React Server Components、流式渲染、Server Actions 支持

但 vinext 仍处于实验阶段(🚧 Experimental),迁移过程远非一帆风顺 — 本文记录了我们在真实生产项目中遇到的 16 个坑位

项目技术栈

组件 技术
框架 Next.js App Router (RSC)
运行时 Cloudflare Workers (Edge Runtime)
数据库 Cloudflare D1 (SQLite)
ORM Prisma + @prisma/adapter-d1
认证 better-auth (从 NextAuth v5 迁移)
国际化 next-intl (en/zh)
存储 Cloudflare R2
缓存 Cloudflare KV
包管理 pnpm

迁移概览

整个迁移涉及 25 个 commits,修改了 50+ 个文件。问题主要集中在以下几个维度:

构建阶段 ─── Prisma Client 模块解析 (3 次迭代)
          └── Wrangler 配置格式转换

运行时 ───── ESM 导入方式变更
          ├── Proxy 导出签名
          ├── 环境变量访问方式
          └── NextURL 只读属性

框架兼容 ─── Middleware matcher 语法
          ├── notFound() 错误处理
          ├── RSC 条件导出
          ├── .rsc 请求处理
          └── Link 组件 vs 原生 <a>

认证系统 ─── DateInt 类型转换
          ├── OAuth State 清理查询
          ├── VerificationToken 主键
          ├── String ↔ Int ID 转换
          └── emailVerified BooleanInt

第一关:Vite 构建 — Prisma Client 解析

症状pnpm build 失败,报 No such module ".prisma/client/default"

原因:Prisma 生成的客户端使用 .prisma/client/default 作为裸模块标识符 (bare specifier)。虽然它以 . 开头,但并不是相对路径 — Node.js 会从 node_modules 中解析它。Vite 把它当作相对路径处理,找不到模块后将其标记为 external,导致 Workers 运行时报错。

踩坑过程(3 次迭代):

第一次:resolve.alias(失败)

// vite.config.ts — 第一次尝试
export default defineConfig({
  resolve: {
    alias: {
      '.prisma/client/default': resolve(import.meta.dirname, 'node_modules/.prisma/client/wasm.js'),
    },
  },
});

问题import.meta.dirname 在 Vite 编译 TypeScript 配置时,可能解析到临时目录而非项目根目录,导致 CI 环境路径错误。

第二次:process.cwd()(部分成功)

// 改用 process.cwd()
'.prisma/client/default': resolve(
  process.cwd(),
  'node_modules/.prisma/client/wasm.js'
),

问题:在 pnpm 的 store 布局下,.prisma/client/wasm.js 不在 node_modules 直接目录中,ENOENT 错误。

第三次:Vite resolveId 插件(最终方案)

function prismaClientResolve(): Plugin {
  const _require = createRequire(import.meta.url);
  let prismaDir: string | null = null;

  // 策略 1: 项目根目录直接查找
  const directPath = resolve(process.cwd(), 'node_modules', '.prisma', 'client');

  // 策略 2: 从 @prisma/client 包位置反向查找(兼容 pnpm store)
  let pnpmPath: string | null = null;
  try {
    const prismaClientPkg = _require.resolve('@prisma/client/package.json');
    pnpmPath = resolve(dirname(prismaClientPkg), '..', '..', '.prisma', 'client');
  } catch {}

  // 选择第一个存在的路径
  for (const candidate of [directPath, pnpmPath].filter(Boolean) as string[]) {
    if (existsSync(candidate)) {
      prismaDir = candidate;
      break;
    }
  }

  return {
    name: 'prisma-client-resolve',
    enforce: 'pre',
    resolveId(source) {
      if (!source.startsWith('.prisma/client')) return null;
      if (!prismaDir) return null;

      const subpath = source === '.prisma/client' ? '' : source.slice('.prisma/client/'.length);

      if (subpath === 'default' || subpath === '') {
        // 优先使用 wasm.js(Workers WASM 引擎)
        const wasmPath = resolve(prismaDir, 'wasm.js');
        if (existsSync(wasmPath)) return wasmPath;

        // 回退到 default.js
        const defaultPath = resolve(prismaDir, 'default.js');
        if (existsSync(defaultPath)) return defaultPath;
      }
      return null;
    },
  };
}

关键点

  • 使用 createRequire@prisma/client 的实际位置反向定位 .prisma/client
  • existsSync 检查避免路径猜测
  • 优先 wasm.js(Workers 兼容)而非 default.js(Node.js 专用)

第二关:Wrangler 配置 — Pages → Workers 格式转换

症状:部署到 Cloudflare 后,环境变量显示为 "preview" 而非 "production"

原因:vinext 使用 Workers 部署(wrangler deploy),但项目的 wrangler 配置还是 Cloudflare Pages 格式。

修改前(Pages 格式):

pages_build_output_dir = ".vercel/output/static"

修改后(Workers 格式):

main = "dist/server/index.js"
no_bundle = true

# vinext 的构建产物是预打包的,告诉 Wrangler 不要重新用 esbuild 打包
[[rules]]
type = "ESModule"
globs = ["**/*.js", "**/*.mjs"]

[assets]
not_found_handling = "none"
directory = "dist/client"

关键点

  • no_bundle = true 是必须的,否则 Wrangler 会用 esbuild 重新打包 vinext 的构建产物,遇到 .prisma/client/default 外部导入时直接报错
  • [[rules]] ESModule 类型声明让 Wrangler 正确处理 vinext 输出的 ES Module 文件
  • [assets] 替代 pages_build_output_dir,指向 vinext 的客户端构建产物

第三关:Workers 运行时 — ESM 与 Proxy 导出

症状:部署后访问任何页面返回 500,Workers 日志无明显错误

问题 1cloudflare:workers 模块必须用静态 ESM 导入

// ❌ 错误 — CJS 动态导入在 Workers 中不工作
const { env } = require('cloudflare:workers');

// ✅ 正确 — 静态 ESM 导入
import { env as cloudflareEnv } from 'cloudflare:workers';

问题 2:vinext 要求 proxy 使用命名导出而非默认导出

// ❌ 错误 — Next.js middleware 的默认导出
export default function middleware(req) { ... }

// ✅ 正确 — vinext 要求命名导出 { proxy }
export async function proxy(req: NextRequest) { ... }

Vitest 兼容cloudflare:workers 在测试环境中不存在,需要 mock:

// vitest.cloudflare-stub.ts
export const env = {};
export default { env };

// vitest.config.ts
resolve: {
  alias: {
    'cloudflare:workers': resolve(__dirname, 'vitest.cloudflare-stub.ts'),
  },
}

第四关:Middleware → Proxy — matcher 语法不兼容

症状:所有页面返回 404,/en 路径抛出 Error 1101(next-intl locale context 缺失)

原因:vinext 的 matchMiddlewarePath 实现中对 matcher pattern 做了 .replace(/\./g, "\\.") 处理。这会破坏 Next.js 标准的正则表达式 matcher:

// Next.js 标准 matcher(包含正则语法)
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\..*).*)'],
};
// 经过 vinext 的 .replace 后变成:
// /((?!api|_next/static|_next/image|.*\\.\\..*)\\..*)
// 完全无法匹配任何路径 → proxy 永远不执行

解决方案:使用 vinext 支持的 :param 语法:

export const config = {
  matcher: ['/:path*'],
};
// vinext 将 :path* 转换为 (?:.*) → 匹配所有路径

附带说明:在 Workers 中不需要排除 _next/static 等静态资源路径,因为 Cloudflare CDN 在请求到达 Worker 之前就会直接提供静态文件。但为了安全,我们在 proxy 中增加了静态文件扩展名检测:

const STATIC_EXTENSIONS =
  /\.(ico|png|jpg|jpeg|gif|svg|webp|css|js|map|woff2?|ttf|eot|webmanifest|txt|xml|json)$/i;

export async function proxy(req: NextRequest) {
  if (STATIC_EXTENSIONS.test(req.nextUrl.pathname)) {
    return NextResponse.next();
  }
  // ...
}

第五关:next-intl — NextURL.port 只读属性

症状:访问任何页面抛出 TypeError: Cannot set property port which has only a getter

原因next-intlcreateIntlMiddleware 内部会修改 NextURL 对象的 port 属性。在标准 Next.js 中 NextURL.port 是可读写的,但 vinext 的 Workers 运行时将其实现为只读 getter。

解决方案:用轻量级的自定义 i18n 路由处理器替代 createIntlMiddleware

function handleI18nRouting(req: NextRequest): NextResponse {
  const { locales, defaultLocale } = routing;
  const pathname = req.nextUrl.pathname;

  // 路径已包含 locale 前缀,直接通过
  const hasLocale = locales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );
  if (hasLocale) return NextResponse.next();

  // 从 Accept-Language 检测首选 locale
  const acceptLanguage = req.headers.get('accept-language') || '';
  let detectedLocale = defaultLocale;
  for (const locale of locales) {
    if (acceptLanguage.toLowerCase().includes(locale)) {
      detectedLocale = locale;
      break;
    }
  }

  // 使用原生 URL 重定向(避免 NextURL setter 问题)
  const url = new URL(req.url);
  url.pathname = `/${detectedLocale}${pathname}`;
  return NextResponse.redirect(url);
}

关键点:使用 new URL(req.url) 而非 req.nextUrl.clone(),因为后者会触发 NextURL 的 setter 逻辑。


第六关:环境变量 — cloudflare:workers vs process.env

症状:认证功能在本地开发正常,部署后报 CLIENT_ID_AND_SECRET_REQUIRED

原因:通过 wrangler secret put 设置的密钥(如 GOOGLE_CLIENT_IDNEXTAUTH_SECRET),只能通过 cloudflare:workersenv 绑定访问,不会出现在 process.env 中。

// ❌ 在 Workers 中永远是 undefined
const secret = process.env.NEXTAUTH_SECRET;

// ✅ 正确方式
import { env as cloudflareEnv } from 'cloudflare:workers';
const secret = (cloudflareEnv as Record<string, unknown>).NEXTAUTH_SECRET;

最终方案:创建统一的环境变量解析函数:

function getEnvVar(key: string): string | undefined {
  // 优先从 Cloudflare Workers 绑定读取
  const cfEnv = cloudflareEnv as Record<string, unknown>;
  if (cfEnv?.[key]) return String(cfEnv[key]);
  // 回退到 process.env(本地开发、测试环境)
  return process.env[key];
}

第七关:notFound() 异常 — NEXT_NOT_FOUND 未捕获

症状:某些页面间歇性返回 500,日志显示 NEXT_NOT_FOUND 异常

原因:vinext 在错误恢复时会用 undefined params 重新渲染 locale layout。代码中的 notFound()(来自 next/navigation)抛出 NEXT_NOT_FOUND 错误,但 vinext 不会像标准 Next.js 那样捕获它,导致整个 RSC 渲染链崩溃。

// ❌ vinext 中不工作
export default async function LocaleLayout({ params }: Props) {
  const { locale } = await params;
  if (!routing.locales.includes(locale)) {
    notFound(); // 抛出 NEXT_NOT_FOUND → 500
  }
}

// ✅ 回退到默认 locale
export default async function LocaleLayout({ params }: Props) {
  const resolvedParams = await params;
  const locale = routing.locales.includes(resolvedParams?.locale)
    ? resolvedParams.locale
    : routing.defaultLocale;
  // 继续渲染...
}

第八关:NextIntlClientProvider — RSC 条件导出冲突

症状:所有页面路由报 Error 1101,日志显示 headers() is not a function

原因next-intlNextIntlClientProvider 使用了 package.json 的 exports 条件导出。在 vinext 的 RSC 环境中,它解析到了一个异步服务端版本,该版本会调用 headers()(来自 next/headers)。但 vinext 不支持在该上下文中调用 headers()

解决方案:创建本地的 'use client' 包装组件:

// app/[locale]/intl-provider.tsx
'use client';

import { IntlProvider } from 'use-intl';

export function ClientIntlProvider({
  locale,
  messages,
  children,
}: {
  locale: string;
  messages: Record<string, unknown>;
  children: React.ReactNode;
}) {
  return (
    <IntlProvider locale={locale} messages={messages as IntlMessages}>
      {children}
    </IntlProvider>
  );
}

关键点

  • 直接从 use-intl(next-intl 的底层库)导入 IntlProvider
  • 'use client' 指令确保 vinext 将其序列化为客户端引用
  • 不使用 NextIntlClientProvider,避免触发服务端条件导出

第九关:RSC 导航 — .rsc 请求被 Auth 拦截

症状:浏览器前进/后退按钮导致页面空白

原因:vinext 使用 .rsc 后缀进行客户端 RSC payload 请求(例如 /en/dashboard.rsc)。proxy 将这些请求当作普通页面请求处理,检查认证状态后重定向到 /login。但 .rsc 请求期望的是 RSC 流数据,收到重定向后 React 无法解析,导致页面空白。

解决方案:在 proxy 中对 .rsc 请求只做 i18n 路由,跳过 auth 检查:

if (pathname.endsWith('.rsc')) {
  // 去掉 .rsc 后缀检查 i18n 路由
  const cleanPath = pathname.slice(0, -4);
  const hasLocale = locales.some(
    locale => cleanPath.startsWith(`/${locale}/`) || cleanPath === `/${locale}`
  );

  if (hasLocale) return NextResponse.next();

  // 添加 locale 前缀并重定向
  const url = new URL(req.url);
  url.pathname = `/${detectedLocale}${pathname}`;
  return NextResponse.redirect(url);
}

安全性:认证在服务端组件层(getSessionSafe())强制执行,不依赖 proxy。


第十关:Link 组件与 API 路由 — RSC 流损坏

症状:用 <Link> 跳转到 API 端点后,浏览器后退显示原始 JSON 而非页面

原因:Next.js 的 <Link> 组件触发客户端 RSC 导航。API 端点返回 JSON 而非 RSC payload,React 尝试解析 JSON 作为 RSC 流失败,破坏了浏览器历史记录中的 React 根节点。

解决方案:对 API 链接使用原生 <a> 标签:

// ❌ 会触发 RSC 导航
<Link href="/api/health">Health Check</Link>

// ✅ 强制全页面导航
<a href="/api/health" target="_blank" rel="noopener noreferrer">
  Health Check
</a>

第十一关:better-auth — Date ↔ Int 类型不匹配

症状:better-auth 的所有数据库写入操作失败

原因:better-auth 内部所有日期字段使用 JavaScript Date 对象,但我们的 Prisma schema 使用 Int(Unix 时间戳,秒)来兼容 D1/SQLite。Prisma 的 SQLite provider 不会自动做这个转换。

解决方案:创建 Prisma Client Proxy,拦截所有 auth 相关模型的操作:

// lib/db/auth-prisma-proxy.ts
const AUTH_DATE_FIELDS: Record<string, string[]> = {
  user: ['emailVerified', 'createdAt', 'updatedAt'],
  account: ['expiresAt', 'createdAt', 'updatedAt'],
  session: ['expires', 'createdAt', 'updatedAt'],
  verificationToken: ['expires', 'createdAt', 'updatedAt'],
};

function deepConvertInputs(obj: unknown, parentKey?: string): unknown {
  if (obj instanceof Date) return Math.floor(obj.getTime() / 1000);
  if (Array.isArray(obj)) return obj.map(item => deepConvertInputs(item));
  if (obj !== null && typeof obj === 'object') {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
      result[key] = deepConvertInputs(value, key);
    }
    return result;
  }
  return obj;
}

export function createAuthPrismaProxy<T>(prisma: T): T {
  return new Proxy(prisma as object, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      if (typeof prop !== 'string') return value;
      const modelKey = resolveModelKey(prop);
      if (!modelKey) return value;

      // 为 auth 模型的每个方法创建代理
      return new Proxy(value as object, {
        get(modelTarget, methodName, modelReceiver) {
          const method = Reflect.get(modelTarget, methodName, modelReceiver);
          if (typeof method !== 'function') return method;
          if (!ALL_OPS.has(methodName as string)) return method.bind(modelTarget);

          return async function (...args: any[]) {
            // 输入:递归转换 Date → Unix timestamp
            if (args[0] && typeof args[0] === 'object') {
              args[0] = deepConvertInputs(args[0]);
            }
            const result = await method.apply(modelTarget, args);
            // 输出:Unix timestamp → Date(仅已知日期字段)
            return convertOutputDates(result, modelKey);
          };
        },
      });
    },
  }) as T;
}

使用方式:

// lib/auth/index.ts
export const auth = betterAuth({
  database: prismaAdapter(createAuthPrismaProxy(prisma), { provider: 'sqlite' }),
  // ...
});

第十二关:OAuth 状态管理 — deleteMany 中的 Date 未转换

症状:Google OAuth 回调后重定向到 ?error=please_restart_the_process

原因:better-auth 的 OAuth 流程中,findVerificationValue 函数在查找状态后会执行清理操作:

// better-auth 内部代码 (state.mjs)
await adapter.deleteMany({
  model: 'verification',
  where: [{ field: 'expiresAt', operator: 'lt', value: new Date() }],
});

我们的 proxy 第一版只拦截了 createupdatefind 操作,且只转换 datacreate/update 字段中的 Date。deleteManywhere 子句中的 new Date() 没有被转换,导致 SQLite 比较失败。

修复:将拦截范围扩展到所有 Prisma 操作(ALL_OPS),并使用递归的 deepConvertInputs 处理整个 args 树:

const ALL_OPS = new Set([
  'create',
  'createMany',
  'update',
  'updateMany',
  'upsert',
  'delete',
  'deleteMany',
  'findFirst',
  'findUnique',
  'findMany',
  'count',
  'aggregate',
]);

第十三关:VerificationToken — 主键缺失与 ID 类型错误

症状:Google OAuth 回调返回 internal_server_error,Workers 日志显示两个错误

错误 1verificationToken.delete({ where: { id: null } })

原因:VerificationToken 模型的 id 字段定义为 String?(可选),没有设置为主键。配合 generateId: false,better-auth 不会为 VerificationToken 生成 ID,导致删除操作传入 id: null

错误 2user.findFirst({ where: { id: "1" } })

原因:better-auth 内部使用 z.coerce.string() 将所有 ID 转换为字符串。但 Prisma schema 中 User 的 idInt,字符串 "1" 无法匹配整数 1

修复

  1. 数据库迁移 — 重建 verification_tokens 表:
-- 0007_fix_verification_token_pk.sql
CREATE TABLE IF NOT EXISTS verification_tokens_new (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  identifier TEXT NOT NULL,
  token TEXT NOT NULL UNIQUE,
  expires INT NOT NULL,
  created_at INT DEFAULT (strftime('%s', 'now')),
  updated_at INT DEFAULT (strftime('%s', 'now')),
  UNIQUE(identifier, token)
);
INSERT INTO verification_tokens_new (identifier, token, expires, created_at, updated_at)
  SELECT identifier, token, expires, created_at, updated_at FROM verification_tokens;
DROP TABLE verification_tokens;
ALTER TABLE verification_tokens_new RENAME TO verification_tokens;
CREATE INDEX idx_verification_tokens_expires ON verification_tokens(expires);
  1. Prisma schema 更新
model VerificationToken {
  id    Int    @id @default(autoincrement())  // 原来是 String?
  // ...
}
  1. 启用 useNumberId — better-auth 内置的 String ↔ Int ID 转换:
// lib/auth/index.ts
export const auth = betterAuth({
  advanced: {
    database: {
      useNumberId: true, // 替代 generateId: false
    },
  },
});

useNumberId: true 让 better-auth 的适配器层自动做 String → Number(输入)和 Number → String(输出)的 ID 转换。


第十四关:emailVerified — Boolean vs Int

症状:Google OAuth 登录时,创建用户失败

原因:当用户通过 Google OAuth 登录时,better-auth 认为邮箱已被 Google 验证,设置 emailVerified: true(布尔值)。但 schema 中 emailVerifiedInt?(Unix 时间戳)。

修复:在 proxy 的 deepConvertInputs 中添加布尔值 → 时间戳转换:

const BOOLEAN_TO_TIMESTAMP_FIELDS = new Set(['emailVerified']);

function deepConvertInputs(obj: unknown, parentKey?: string): unknown {
  if (obj instanceof Date) return Math.floor(obj.getTime() / 1000);

  // 特定字段的 boolean → timestamp 转换
  if (typeof obj === 'boolean' && parentKey && BOOLEAN_TO_TIMESTAMP_FIELDS.has(parentKey)) {
    return obj ? Math.floor(Date.now() / 1000) : null;
  }

  // ... 递归处理
}

第十五关:D1 迁移 — ALTER TABLE 不支持动态默认值

症状wrangler d1 migrations apply 报错 non-constant default

原因:SQLite 的 ALTER TABLE ADD COLUMN 语句不允许使用非常量默认值(如 strftime('%s', 'now'))。

-- ❌ 错误
ALTER TABLE accounts ADD COLUMN created_at INT DEFAULT (strftime('%s', 'now'));

-- ✅ 正确 — 先用常量默认值,再用 UPDATE 回填
ALTER TABLE accounts ADD COLUMN created_at INT DEFAULT 0;
UPDATE accounts SET created_at = strftime('%s', 'now') WHERE created_at = 0;

第十六关:CI/CD — Workers 域名包含账户子域

症状:GitHub Actions 部署成功但健康检查失败(HTTP 000)

原因:Cloudflare Workers 的默认域名格式是 <worker-name>.<account-subdomain>.workers.dev,而不是 <worker-name>.workers.dev。CI 配置中的回退 URL 少了账户子域。

# ❌ 错误
DEPLOYMENT_URL="https://my-worker.workers.dev"

# ✅ 正确
DEPLOYMENT_URL="https://my-worker.t-ac5.workers.dev"

最佳实践:在 GitHub Repository Variables 中配置 TEST_DEPLOYMENT_URLPRODUCTION_DEPLOYMENT_URL,不要依赖 hardcoded 回退值。


核心代码清单

迁移后的核心文件结构:

├── vite.config.ts              # Vite 配置 + Prisma resolveId 插件
├── proxy.ts                    # 替代 middleware.ts(i18n + auth + CORS/CSRF)
├── wrangler.toml               # 本地开发配置(Workers 格式)
├── wrangler.test.toml          # 测试环境配置
├── wrangler.prod.toml          # 生产环境配置
├── vitest.cloudflare-stub.ts   # cloudflare:workers 测试 mock
├── lib/
│   ├── auth/
│   │   ├── index.ts            # better-auth 配置 + getEnvVar
│   │   ├── session.ts          # getSessionSafe (RSC 安全包装)
│   │   └── password.ts         # PBKDF2 密码哈希(Edge 兼容)
│   └── db/
│       ├── client.ts           # Prisma 单例 + cloudflare:workers ESM 导入
│       └── auth-prisma-proxy.ts # Date↔Int + Boolean→Int 类型转换代理
├── app/[locale]/
│   └── intl-provider.tsx       # 本地 'use client' IntlProvider 包装
└── migrations/
    └── 0007_fix_verification_token_pk.sql  # VerificationToken 主键修复

总结

从 Next.js 迁移到 vinext 不是简单的"换个构建工具"那么简单。主要挑战来自三个层面:

  1. Vite vs webpack 的模块解析差异:Prisma 的裸模块标识符、条件导出的解析优先级等,在两个构建系统中行为完全不同。

  2. Workers vs Node.js 运行时差异process.env 不可用、NextURL 属性只读、notFound() 未被捕获 — 这些都是 Workers 运行时的限制。

  3. 第三方库假设:next-intl 假设 NextURL.port 可写,better-auth 假设日期字段是 Date 对象 — 这些假设在标准 Next.js 中成立,但在 vinext 的 Workers 环境中全部失效。

核心教训:vinext 不是 Next.js 的替代品,而是 Next.js API 在 Workers 运行时上的重新实现。任何依赖 Next.js 实现细节(而非公开 API)的代码,都可能需要适配。

本文基于 2026 年 2 月的实际迁移经验,vinext 仍在快速迭代中,部分问题可能在后续版本中得到解决。

Cloudflare 掀桌子了,Next.js 迎来重大变化,尤雨溪都说酷!

Vite 作为 2025 年满意度最大的技术,下载量已经超过的 webpack,加入 Vite 工具链变成一种趋势,而这次是 Nextjs!

Next.js 13.4 之前,webpack 是默认的构建工具,Next.js 13.4 及以上版本引入了 Turbopack 作为开发环境的默认工具,但生产环境目前仍主要依赖 webpack 进行最终打包。

2 月 25 日,Cloudflare 发布了 Vinext

在 Vite 上重新实现了 Next.js 的 API,让开发者可以脱离官方的 webpack 和 Turbopack,转而使用 Vite 来 构建 Next.js 应用。

AI 开发,人类主导

Vinext 是一个极具探索性的项目。

使用 Claude Code 开发:

  • 绝大部分代码
  • 测试
  • 文档
  • 一周内完成

架构、优先级和设计决策则由人类主导。

AI驱动的开发过程

  1. 适配 AI 开发的条件:Next.js 文档/测试完善、Vite 提供坚实基础、新一代 AI 模型可处理大型代码库复杂度
  2. 开发流程:定架构拆任务AI写代码+测试自动验证+迭代
  3. 投入:800+ 次 OpenCode 会话,代码通过严格的测试、类型检查与CI验证

AI 完成代码评审,人工把控方向与纠错。

Vinext 的核心

Vinext 底层基于 Vite 开发,开发者可以直接享受到 Vite 庞大的、干净且标准化的插件生态。

对于 Next.js 来说,目前的痛点:

  • 本地服务器启动慢
  • HMR 速度慢
  • 无服务器生态适配难
  • 与 Vercel 强绑定,部署至第三方平台配置复杂
  • 适配方案 OpenNext 成本高、维护困难
  • 开发与构建流程与 Node.js 强绑定,跨平台体验差

而 Vinext 的优势非常明显👇🏻

极致的性能表现

vinext 通过底层重构,显著提升了 Next.js 在开发和生产阶段的性能指标。

  • 构建速度飞跃:生产环境提速 4 倍
  • 包体积更小:体积比原生 Next.js 缩小了 57%
  • 瞬时 HMR:开发环境下,瞬时热更新,极致的开发体验。

边缘原生,解决部署痛点

  • 零冷启动部署:几乎没有冷启动延迟。
  • 开发生产一致性:支持在开发环境调用 Cloudflare 的平台 API,避免开发环境和生产环境不一致的尴尬。
  • 脱离厂商锁定:不和 Vercel 强绑定,可自由地部署在第三方平台。

创新技术:流量感知预渲染

这是 Vinext 最具差异化的技术优势:

  • 精准优化:通过真实分析数据,识别出那些 10% 贡献了 90% 流量的热门页面。
  • 按需生成:预渲染高频页面,保证用户的极速体验,缩短大型站点构建时间,避免冷门页面的浪费。

迁移与兼容成本极低

  • 无感迁移:支持一键转换,不破坏原有的 Next 配置,做到共存
  • 高覆盖率 API:实现了约 94% 的 Next.js 16 API
  • AI 赋能:专门的 AI Agent Skill,让 AI 助手自动处理迁移冲突

快速上手

安装:

npm install vinext

替换 Next:

{
  "scripts": {
    "dev": "vinext dev",
    "build": "vinext build",
    "start": "vinext start"
  }
}

核心命令:

# 启动开发环境
vinext dev   
# 构建打包
vinext build
# 构建并部署的到 Cloudflare Workers
vinext deploy       

AI 迁移助手

用 AI 助手帮助你将旧项目迁移至 Vinext。

安装 AI Skills

npx skills add cloudflare/vinext

在AI编码工具执行迁移命令,自动完成适配

npx skills add cloudflare/vinext

总结

cloudflare 推出 Vinext 无疑是开始掀桌子了,在发布后就获得了广泛的讨论,虽然该项目还处在实验阶段,但已经有一些项目开始使用了。

对于 Next.js 开发者,如果你想体验:

  • Vite 生态
  • 极致的开发体验
  • 高效的打包构建
  • 零启动的 Cloudflare 访问

不妨关注一下这个项目。

Tanstack Start 的天才创新之处——基于实际使用体验

最近正在使用 Tanstack Start 写一个 YouTube 视频转技术文章的 AI 应用。这是我第一次使用该框架,缘起是阅读了一篇文章我用 10 种框架开发了同款应用:移动端性能框架评估,其中一个结论:如果你需要使用 React,即 React 包体积没法避免的情况下,选择 TanStack Start 优于 Next.js。

接下来基于实际用体验我们聊一聊 Tanstack Start 的创新之处

按照哇哦程度从小到大排序。

创新一:路由类型提示 ⭐⭐⭐

Link 和 navigate 均可,这在其他框架目前是没有做到的。原理是根据路由文件自动生成路由类型文件 src/routeTree.gen.ts

但是 search 和 location.state 目前还没有,这个有点遗憾。

创新二:文件自动生成 ⭐⭐⭐⭐

要将新路由添加到应用中,只需在 ./src/routes 目录下新建一个文件。

TanStack 将自动为您生成该路由文件的内容

以前大家做模板生成是怎么做的?命令行手动执行 foo-cli gen new-page 然后生成模板文件,容易忘记。Tanstack 更高级或更智能的一点是你只要按照你自己的习惯去生成文件,内容自动给你填充好,这完全贴切我们的开发流程,一比较手动执行 cli 弱爆了,而且还可以做到内容动态生成,因为它知道你正在哪一个目录新增文件。

比如你在 routes 下则给你上传路由文件,在 services 则给你生成 service 代码,在 components 下则生成组件模板,……。真得太妙了,Next.js 得学我们也得学,学这种不违背自然规律“大音希声大象无形”的思想。

出自《道德经》“大方无隅,...,大音希声,大象无形。”这是由老子提出的中国古代文学理论中的一种美学观念,意在推崇自然的、而非人为的美。

—— 百度百科

foo-cli gen new-page 是人为的美,新增文件后自动生成根据目录而变化的模板内容是自然的美。

创新三:代码定位“一键打开源码” ⭐⭐⭐⭐⭐

先剧透下,最后效果绝对惊艳!宛如第一次看“一剑开天门”的震撼感。

比如我想要修改以下 header 样式,需要复制长长的 class 然后全局搜索,但有时候类名是动态拼接的,不一定能搜索到,这时候只能去除一部分 class,逐次删除尝试,是不是很麻烦?现在好了 Tanstack Start 直接在每一个元素增加了 data-tsd-source 复制到编辑器如 VSCode Ctrl+PCtrl + P 直接精确到行列打开,直捣黄龙!

到这里 Tanstack 就停止了它的“美学追求”了吗?并没有!还有更厉害的绝对惊艳你,你看到后面一定会发出当初和我一样“哇哦 AMAZING”的惊叹 🤩。

代码实例:

<header
  class="p-4 flex items-center bg-gray-800 text-white shadow-lg"
  data-tsd-source="/src/components/Header.tsx:23:7"
>
  <button
    class="p-2 hover:bg-gray-700 rounded-lg transition-colors"
    data-tsd-source="/src/components/Header.tsx:24:9"
  >
    <svg data-tsd-source="/src/components/Header.tsx:29:11">
      <path d="M4 5h16"></path>
      <path d="M4 12h16"></path>
      <path d="M4 19h16"></path>
    </svg>
  </button>
  <h1 data-tsd-source="/src/components/Header.tsx:31:9">
    <a data-tsd-source="/src/components/Header.tsx:32:11" href="/">
      <img
        src="/tanstack-word-logo-white.svg"
        alt="TanStack Logo"
        data-tsd-source="/src/components/Header.tsx:33:13"
      />
    </a>
  </h1>
</header>

image.png

生成的每一个html 元素都有 data-tsd-source,已经非常方便定位源码了,唯一不方便是得删除开头的 / 否则直接输入 data-tsd-source 路径无法定位到具体文件。能否有编译设置?

我们来一步步了解。

首先 data-tsd-source 是 Tanstack Start 的特色,通过 @tanstack/devtools-vite injectSource 控制引入:

// vite.config.ts
import { devtools } from "@tanstack/devtools-vite"

const config = defineConfig({
  plugins: [
    devtools({
      injectSource: {
        enabled: false,
      },
    }),
  ]
});

我们可以用 enabled: false 关闭。当然这里只是说明其确实是被 devtools 引入的。接下来我们要配置达到删除开头 /,在翻阅文档和 issue 我们发现关键词“Click-to-code”,难道 Tanstack devtool 支持点击即可打开源码!

文档 Go to source,证实我们确实可以点击即打开源码!

Go to source  前往源代码

Allows you to open the source location on anything in your browser by clicking on it.
允许您通过点击在浏览器中打开任何内容的源代码位置

To trigger this behavior you need the Devtools Vite plugin installed and configured and the Panel available on the page. Simply click on any element while holding down the Shift and Ctrl (or Meta) keys.
要触发此行为,您需要安装并配置 Devtools Vite 插件,并且页面上需要有面板可用。只需在按住 Shift 和 Ctrl(或 Meta)键的同时点击任何元素即可。

触发方式:Ctrl+Shift+ClickCtrl + Shift + Click(Windows)点击你想要定位的 HTML 元素,哇哦简直 AMAZING。

也就是我们无需配置删除开头 / 了,Tanstack Devtool 将体验再拔高一个档次!之前:

  1. Ctrl + Shift + P Chrome Devtool 定位到 HTML 元素
  2. 复制 data-tsd-source 属性内容
  3. 打开你常用编辑器(trae 或 VSCode):输入删除开头 / 的 path

现在我们可以一步到位:

Ctrl+Shift+ClickCtrl + Shift + Click 点击 HTML 元素“一剑开天门”。

https://image.baidu.com/search/detail?adpicid=0&b_applid=8600553881827950046&bdtype=0&commodity=&copyright=&cs=232301967%2C4287296332&di=7565560840087142401&fr=click-pic&fromurl=http%253A%252F%252Fnew.qq.com%252Fomn%252F20220228%252F20220228a0bwqe00.html&gsm=0&hd=&height=0&hot=&ic=&ie=utf-8&imgformat=&imgratio=&imgspn=0&is=0%2C0&isImgSet=&latest=&lid=&lm=&objurl=https%253A%252F%252Finews.gtimg.com%252Fnewsapp_bt%252F0%252F14571654986%252F1000&os=2777311845%2C3505696742&pd=image_content&pi=0&pn=1&rn=1&simid=4258744905%2C700113141&tn=baiduimagedetail&width=0&word=%E4%B8%80%E5%89%91%E5%BC%80%E5%A4%A9%E9%97%A8&z=&extParams=%7B%22fromPn%22%3A21%2C%22fromCs%22%3A%222395995045%2C1582987340%22%7D

接下来是见证奇迹的时刻:一点自动打开源码。

但是实际上并没有,啥也没发生!等会讲原因。

还是回到这个 issue Click-to-code does not work when command run from different directory #281

我们尝试修改下 issue 内提供代码,将其改成我常用的 Trae

// vite.config.ts

// 改编自 https://github.com/TanStack/devtools/issues/281#issuecomment-3607468808
const open = async (filePath, lineNumber, columnNumber) => {
  const filePathString = `${filePath.replaceAll("$", "\\$")}${
    lineNumber ? `:${lineNumber}` : ""
  }${columnNumber ? `:${columnNumber}` : ""}`;

  const launch = (await import("launch-editor")).default;

  // if trae is available, use it otherwise use the default editor
  const editorCli: string | undefined = await (async () => {
    try {
      // trae is global command use which to check if it is available use execSync
      const { exec } = await import("node:child_process");
      const { promisify } = await import("node:util");
      const execPromise = promisify(exec);
      const { stdout } = await execPromise("which trae1");

      console.log("stdout", stdout)

      if (stdout) {
        return "trae";
      }

      return undefined; // use default editor
    } catch (error) {
      console.error("Error checking for trae:", error);
      console.error("Error checking for trae fallback to default editor");
      return undefined;
    }
  })();

  console.log("launch-editor", {
    filePath,
    editorCli,
    lineNumber,
    columnNumber,
  });

  // https://bgithub.xyz/yyx990803/launch-editor?tab=readme-ov-file#usage
  launch(filePathString, editorCli, (filename, err) => {
    console.warn(`Failed to open ${filename} in editor: ${err}`);
  });
};

日志:

stdout { stdout: '/e/app2/TraeCN/bin/trae\n', stderr: '' }
launch-editor {
  filePath: 'F:/workspace/github/my-tanstack-app-pnpm/src/routes/index.tsx',
  editorCli: 'trae',
  lineNumber: '97',
  columnNumber: '15'
}

https://inews.gtimg.com/newsapp_bt/0/14571655004/1000

这下成功了,点击元素后 Trae 自动打开源码具体到行号和列号 🎉。

现在我也知道为什么刚开始不行因为我们还没打开 VSCode 呢(但 Trae 是打开)。当然前提条件必须将 codetrae 安装成全局命令。

如果你常用编辑器是 VSCode,那么这段配置也无需,不过首先你得打开 VSCode,后续 Ctrl+Shift+ClickCtrl + Shift + Click 才会起作用。因为我用 Trae 故仍需配置。

完整配置文件如下:

// vite.config.ts
import tailwindcss from "@tailwindcss/vite"
import { devtools } from "@tanstack/devtools-vite"
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
import viteReact from "@vitejs/plugin-react"

import { nitro } from "nitro/vite"
import { defineConfig } from "vite"
import viteTsConfigPaths from "vite-tsconfig-paths"

const config = defineConfig({
  plugins: [
    devtools({
      editor: {
        name: "Shift + Ctrl + Click to open element src in editor",
        open: async (filePath, lineNumber, columnNumber) => {
          const filePathString = `${filePath}${[
            lineNumber && `:${lineNumber}`,
            columnNumber && `:${columnNumber}`,
          ]
            .filter(Boolean)
            .join("")}`

          const launch = (await import("launch-editor")).default
          const { exec } = await import("node:child_process")
          const { promisify } = await import("node:util")
          const execPromise = promisify(exec)
          const myEditor = "trae"

          // if trae is available, use it otherwise use the default editor
          const editorCli: string | undefined = await (async () => {
            try {
              // trae is global command use which to check if it is available use execSync
              await execPromise(`which ${myEditor}`)

              return myEditor
            } catch (_error) {
              // console.warn(`Error checking for ${myEditor}:`, error)
              console.warn(
                `Error checking for ${myEditor} fallback to default editor`,
              )
              return undefined
            }
          })()

          console.info("launch-editor", editorCli, {
            filePath,
            lineNumber,
            columnNumber,
            filePathString,
          })

          // https://bgithub.xyz/yyx990803/launch-editor?tab=readme-ov-file#usage
          launch(filePathString, editorCli, (filename, err) => {
            throw new Error(`Failed to open ${filename} in editor: ${err}`)
          })
        },
      },
    }),
    nitro(),
    // this is the plugin that enables path aliases
    viteTsConfigPaths({
      projects: ["./tsconfig.json"],
    }),
    tailwindcss(),
    tanstackStart(),
    viteReact(),
  ],
})

export default config

这是最佳解决办法了吗?并非!

配置又长又臭,还能有别的办法让其在 trae 打开吗?

我们仔细阅读 launch-editor 这个 600 万周下载量的包,作者 yyx 尤雨溪,从 react-dev-utils 抽离成单独包:

从 Node.js 中在编辑器中打开带行号的文件。

主要功能是从 react-dev-utils 中提取的,经过轻微修改,以便可以作为独立包使用。原始源代码遵循 MIT 许可证。

也增加了列号支持。

—— github.com/yyx990803/l…

yyx 还提到:

然而,其他包需要设置环境变量如 EDITOR 才能打开文件。该包在回退到环境变量之前,会检查当前运行进程以推断要打开的编辑器。

这就解释了,当我们并未打开 VSCode(进程未运行),且未设置环境变量 launch-editor 自然无法打开编辑器。

image.png

“一些漫不经心的说话,将我疑惑解开。一种莫名其妙的冲动,叫我继续追寻”,到这里恍然大悟 💡,除了通过“又长又臭的”配置强制切换编辑器,还可以通过环境变量来指定

环境配置存在两种方式:

  1. 私人环境变量
// ~/.zshrc
export LAUNCH_EDITOR=trae
echo $LAUNCH_EDITOR
trae

点击页面元素,确实可以通过环境变量指定的编辑器打开源码。

  1. 项目环境变量

我们再试试 .env 文件。.env 的好处是动态修改动态生效,无需重启 Terminal 以及 dev server。

// 项目根目录 .env
LAUNCH_EDITOR=trae

Trae 成功打开。

切换 LAUNCH_EDITOR

// 项目根目录 .env
LAUNCH_EDITOR=code

VSCode 成功打开。

.env 缺点是放到工程里面如果其他同事常用编辑器和你不一样就会有问题了。故我们还是选择放到 ~/.zshrc 因为它是私人的 ~ 表示个人目录。

故最终配置:出于尊重同事习惯,我们删除了 vite.config.ts 的 devtools editor:

// vite.config.ts

import tailwindcss from "@tailwindcss/vite"
import { devtools } from "@tanstack/devtools-vite"
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
import viteReact from "@vitejs/plugin-react"

import { nitro } from "nitro/vite"
import { defineConfig } from "vite"
import viteTsConfigPaths from "vite-tsconfig-paths"

const config = defineConfig({
  plugins: [
    devtools(),
    nitro(),
    // this is the plugin that enables path aliases
    viteTsConfigPaths({
      projects: ["./tsconfig.json"],
    }),
    tailwindcss(),
    tanstackStart(),
    viteReact(),
  ],
})

export default config

还以清爽的 vite.config.ts,在私人环境变量中配置:

// ~/.zshrc

export LAUNCH_EDITOR=trae

这样配置代码量最少,又能尊重他人习惯,“和而不同”。

🔬 探究 TanStack Ctrl+Shift+Click 源码跳转功能

如果我们要做自己一个类似功能,应该怎么做呢。

元素点击后我们看到这样一个网络请求:

http://localhost:3000/__tsd/open-source?source=%2Fsrc%2Froutes%2Fyoutube-article-generator%2Farticles%2F%24id.tsx%3A429%3A15

简化:

GET /src/routes/youtube-article-generator/articles/$id.tsx:429:15

很简单其实就是点击的那一刻发送了一个 GET 请求,服务端接收到后调用 launch-editor 利用 Node.js 本地能力打开编辑器,我猜的。看看源码吧。

TanStack Start React 项目通过 @tanstack/devtools-vite 插件实现 Ctrl+Shift+Click 点击元素打开源码的功能。该功能包含两个核心部分:源码注入和点击处理。

1. 源码注入

Vite 插件在开发模式下为 JSX 元素注入 data-tsd-source 属性:

// 转换前
<div>Hello World</div>

// 转换后  
<div data-tsd-source="src/App.tsx:5:1">Hello World</div>

插件通过 AST 转换实现,使用 Babel 解析 JSX 并添加位置信息。

2. 点击事件处理

DevTools 组件监听全局点击事件,检测 Ctrl+Shift 组合键:

const openSourceHandler = (e) => {
  const isShiftHeld = e.shiftKey
  const isCtrlHeld = e.ctrlKey || e.metaKey
  if (!isShiftHeld || !isCtrlHeld) return
  
  if (e.target instanceof HTMLElement) {
    const dataSource = e.target.getAttribute('data-tsd-source')
    if (dataSource) {
      // 发送请求到开发服务器
      fetch(`${location.origin}/__tsd/open-source?source=${encodeURIComponent(dataSource)}`)
    }
  }
}

3. 服务器端处理

Vite 插件的服务器中间件处理 __tsd/open-source 请求,调用编辑器打开文件。

默认使用 launch-editor 库打开 VS Code。

配置使用

vite.config.ts 中启用插件:

import { devtools } from '@tanstack/devtools-vite'

export default defineConfig({
  plugins: [
    devtools({
      injectSource: { enabled: true }, // 启用源码注入
      editor: { // 自定义编辑器配置
        name: 'VSCode',
        open: async (path, lineNumber, columnNumber) => {
          // 自定义打开逻辑
        }
      }
    })
  ]
})

[!NOTE]

  • 该功能仅在开发模式下工作,生产构建时会自动移除相关代码
  • 插件会跳过包含 {...props} 属性展开的 JSX 元素,避免冲突
  • 支持自定义编辑器配置,可适配 WebStorm、Cursor 等其他编辑器

如果对你有所启发,不妨关注公众号“JavaScript与编程艺术”。

源码摘要

File: packages/devtools-vite/src/inject-source.ts (L110-152)

const transformJSX = (
  element: NodePath<t.JSXOpeningElement>,
  propsName: string | null,
  file: string,
) => {
  const loc = element.node.loc
  if (!loc) return
  const line = loc.start.line
  const column = loc.start.column
  const nameOfElement = getNameOfElement(element.node.name)

  if (nameOfElement === 'Fragment' || nameOfElement === 'React.Fragment') {
    return
  }
  const hasDataSource = element.node.attributes.some(
    (attr) =>
      attr.type === 'JSXAttribute' &&
      attr.name.type === 'JSXIdentifier' &&
      attr.name.name === 'data-tsd-source',
  )
  // Check if props are spread
  const hasSpread = element.node.attributes.some(
    (attr) =>
      attr.type === 'JSXSpreadAttribute' &&
      attr.argument.type === 'Identifier' &&
      attr.argument.name === propsName,
  )

  if (hasSpread || hasDataSource) {
    // Do not inject if props are spread
    return
  }

  // Inject data-source as a string: "<file>:<line>:<column>"
  element.node.attributes.push(
    t.jsxAttribute(
      t.jsxIdentifier('data-tsd-source'),
      t.stringLiteral(`${file}:${line}:${column + 1}`),
    ),
  )

  return true
}

File: packages/devtools/src/devtools.tsx (L162-188)

  createEffect(() => {
    // this will only work with the Vite plugin
    const openSourceHandler = (e: Event) => {
      const isShiftHeld = (e as KeyboardEvent).shiftKey
      const isCtrlHeld =
        (e as KeyboardEvent).ctrlKey || (e as KeyboardEvent).metaKey
      if (!isShiftHeld || !isCtrlHeld) return

      if (e.target instanceof HTMLElement) {
        const dataSource = e.target.getAttribute('data-tsd-source')
        window.getSelection()?.removeAllRanges()
        if (dataSource) {
          e.preventDefault()
          e.stopPropagation()
          fetch(
            `${location.origin}/__tsd/open-source?source=${encodeURIComponent(
              dataSource,
            )}`,
          ).catch(() => {})
        }
      }
    }
    window.addEventListener('click', openSourceHandler)
    onCleanup(() => {
      window.removeEventListener('click', openSourceHandler)
    })
  })

File: packages/devtools-vite/src/plugin.ts (L120-131)

        server.middlewares.use((req, res, next) =>
          handleDevToolsViteRequest(req, res, next, (parsedData) => {
            const { data, routine } = parsedData
            if (routine === 'open-source') {
              return handleOpenSource({
                data: { type: data.type, data },
                openInEditor,
              })
            }
            return
          }),
        )

File: packages/devtools-vite/src/editor.ts (L26-38)

export const DEFAULT_EDITOR_CONFIG: EditorConfig = {
  name: 'VSCode',
  open: async (path, lineNumber, columnNumber) => {
    const launch = (await import('launch-editor')).default
    launch(
      `${path.replaceAll('$', '\\$')}${lineNumber ? `:${lineNumber}` : ''}${columnNumber ? `:${columnNumber}` : ''}`,
      undefined,
      (filename, err) => {
        console.warn(`Failed to open ${filename} in editor: ${err}`)
      },
    )
  },
}
❌