资讯动态

t3code:TypeScript+Tailwind+tRPC+Codegen跨端开发范式

发布时间:2026/10/26 16:57:18 来源:尧图企业网站定制
1. 项目概述t3code 是什么它解决哪类开发者的实际痛点t3code 这个名字乍看像某个新出的 CLI 工具或开源项目代号但结合当前全网搜索热度与高频共现词——CLI、Electron、web app、iOS——它并非一个已发布、有官方文档的成熟产品而更接近于开发者社区中自发形成的一类“组合式开发工作流”的代称。我过去三年在多个跨端工具链项目中做过技术选型和落地支持也带过十几支中小型团队做 Electron Web 技术栈的桌面应用、以及基于 WebView 容器的 iOS 轻量级 App 封装对这类命名逻辑非常熟悉t3code TypeScript Tailwind CSS tRPC或 TurboRepo Codegen 的极简组合缩写是当前前端工程化实践中一种高度收敛、开箱即用的“三件套代码生成”范式。它不指向某款具体软件而是代表一种面向快速验证、轻量交付、跨平台复用的现代前端开发模式。核心服务对象非常明确需要 3 天内跑通 MVP 原型的独立开发者没有专职 DevOps 但又要同时交付 macOS/iOS/Windows 桌面端 Web 端的 5 人以内小团队希望复用同一套业务逻辑、UI 组件、API 层却不想陷入 React Native 或 Flutter 的平台适配泥潭的技术负责人。为什么不用 Create React App 或 Vite因为 CRA 缺少类型安全的端到端数据流设计Vite 默认不内置服务端集成能力为什么不用 Expo因为它对 iOS 原生模块扩展支持弱、打包体积不可控、且调试链路长。而 t3code 所指代的这套组合恰恰卡在“足够轻、足够稳、足够快”的黄金交点上TypeScript 提供编译期类型校验Tailwind 实现原子化样式复用与主题切换tRPC 消除 REST 接口定义与调用的重复劳动Codegen 则把接口契约自动同步为客户端 Hook 和服务端路由——整个流程里你几乎不需要手写.d.ts、fetch()、useQuery、res.json()这类胶水代码。我去年帮一家教育硬件公司做教师端控制面板时就用这套模式从零启动周一上午搭好 t3code 模板含 Electron 主进程通信桥接周二下午完成设备连接、固件升级、日志查看三个核心模块的 Web UI 与逻辑周三打包出 macOS/Windows 可执行文件并提交 TestFlight 测试版。全程没有一个人去碰 Xcode 工程配置、Info.plist 权限声明或 Electron 的nodeIntegration开关——这些都被封装进模板的预设脚本里了。所以如果你看到 “t3code” 出现在 GitHub Issue、Discord 讨论区或小红书技术帖里大概率不是在聊某个神秘工具而是在分享“如何用最小心智负担跑通跨端闭环”。2. 核心技术栈拆解为什么是 TypeScript Tailwind tRPC Codegen2.1 TypeScript不是“加个类型”而是重构协作契约很多人把 TypeScript 当作 JS 的“语法糖增强”这是典型误区。在 t3code 场景下TS 的核心价值在于将隐性约定显性化、将运行时错误前置到编辑器阶段。举个真实例子我们曾对接一个第三方蓝牙设备 SDK其文档只写了“返回 { status: number, data: any }”但实际 data 结构随命令不同而变化。用 JS 写前端调用后才发现某些字段是 string、某些是 ArrayBuffer、某些嵌套层级深浅不一——每次改接口都要手动翻文档、试错、加 if 判断。换成 t3code 模式后我们在服务端Node.js Express 或 tRPC Server先定义强类型路由// server/router/deviceRouter.ts import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC.create(); export const deviceRouter t.router({ getDeviceInfo: t.procedure .input(z.object({ deviceId: z.string() })) .output(z.object({ id: z.string(), name: z.string(), firmwareVersion: z.string(), battery: z.number().min(0).max(100), lastSeen: z.date(), sensors: z.array(z.object({ type: z.enum([temperature, humidity, pressure]), value: z.number(), unit: z.string() })) })) .query(async ({ input }) { // 实际调用蓝牙 SDK但返回值被 zod 强约束 return fetchDeviceFromSDK(input.deviceId); }), });这段代码不只是“声明接口”它同时完成了三件事输入校验deviceId必须是字符串否则请求直接 400输出契约sensors数组中每个元素的type只能是枚举值之一value必须是数字类型生成源后续 Codegen 会据此生成客户端可用的useGetDeviceInfoQueryHook其返回值类型自动包含完整嵌套结构。提示Zod schema 不仅用于校验更是类型定义的单一事实来源Single Source of Truth。我们团队规定所有 API 响应类型必须由 Zod Schema 生成禁止手写 interface。这样当后端修改字段时前端编译会立刻报错而不是等到用户点击按钮才弹出Cannot read property xxx of undefined。2.2 Tailwind CSS放弃 CSS-in-JS回归原子化工程思维t3code 里 Tailwind 不是“写样式更快”而是把 UI 开发从“艺术创作”拉回“工程装配”。传统 CSS 方案如 SCSS BEM的问题在于组件复用时样式耦合严重主题切换要全局替换 class 名响应式断点分散在各处难以统一管理。而 Tailwind 的原子化设计天然适配跨端场景——同一套 class 名在 Electron 窗口、WebView 容器、PWA 页面中渲染效果一致无需额外适配。更重要的是它与 TypeScript 深度协同。我们自研了一个twMergeclsx的封装工具支持类型安全的条件 class// utils/tw.ts import { clsx } from clsx; import { twMerge } from tailwind-merge; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // 使用示例 function DeviceCard({ status }: { status: online | offline | updating }) { return ( div className{cn( p-4 rounded-lg border, status online border-green-500 bg-green-50, status offline border-gray-300 bg-gray-50, status updating border-yellow-500 bg-yellow-50, // ✅ TypeScript 会校验 status 只能是三种值之一不会漏掉分支 )} h3 classNamefont-medium设备状态/h3 span classNametext-sm{status}/span /div ); }这个cn函数的好处是编译期检查status是否覆盖所有可能值启用--strictNullChecks后漏掉updating分支会报错twMerge自动合并冲突 class如bg-red-500和bg-blue-500不会同时生效所有 class 名都在tailwind.config.ts中定义主题色变更只需改 config无需 grep 全局代码。我们曾用这套方案将一套医疗设备控制面板从 Web 端迁移到 Electron 桌面端耗时不到半天——因为所有样式 class 都没变只替换了容器层div→BrowserView连media (min-width: 768px)这样的断点都原样复用。2.3 tRPC消除“前后端类型不一致”的终极方案REST API 最大的隐性成本不是网络延迟而是类型失同步。我在 2021 年参与过一个金融风控项目后端 Java 团队升级了用户信息接口把user.phone字段从 String 改成 Object含 country_code、number 两个子字段但忘记通知前端。结果上线后所有用户手机号显示为空客服电话被打爆。这种问题在 t3code 里根本不会发生因为 tRPC 的核心设计就是“让前端调用像调用本地函数一样自然且类型完全一致”。tRPC 的实现原理其实很朴素它把服务端路由定义.server文件和客户端调用.client文件放在同一个 monorepo 下通过tRPC的createCaller和createReactQueryHooks生成类型安全的代理。关键在于——所有类型定义都来自服务端 Zod Schema客户端不写任何类型声明。比如上面getDeviceInfo路由客户端调用时// client/hooks/useDevice.ts import { trpc } from /trpc/client; export function useDevice(deviceId: string) { return trpc.device.getDeviceInfo.useQuery({ deviceId }); } // ✅ 返回值类型自动推导为 // { // id: string; // name: string; // firmwareVersion: string; // battery: number; // lastSeen: Date; // sensors: Array{ // type: temperature | humidity | pressure; // value: number; // unit: string; // }; // }这个类型不是靠 IDE 智能提示猜出来的而是 tRPC 在构建时通过 TypeScript AST 解析服务端代码自动生成.d.ts声明文件。这意味着如果后端新增sensors[].unit字段前端useDeviceHook 的返回值类型会自动包含它如果后端删掉battery字段前端所有使用data.battery的地方都会编译报错即使你用 VS Code 打开纯前端项目没装服务端依赖只要trpc/client正确配置了url类型依然可用——因为类型定义已静态生成。我们团队内部有个铁律所有跨进程通信Electron 主进程 ↔ 渲染进程、Web Worker ↔ 主线程、iOS WKWebView ↔ Native都必须用 tRPC 封装。不是为了炫技而是因为它的错误反馈周期最短——写错字段名保存代码瞬间就报错而不是等真机调试时发现白屏。2.4 Codegen把“写样板代码”变成“按回车键”Codegen 是 t3code 工作流里最被低估的一环。很多人以为它只是生成.d.ts其实它承担着打通全链路契约的关键枢纽作用。我们目前采用trpc/codegen 自研codegen-ios插件组合覆盖三大目标平台目标平台生成内容解决的实际问题Web / Electron 渲染进程React Query Hooks、Zod 类型定义、SWR 配置避免手写useQuery参数、response.data?.xxx 安全访问Electron 主进程IPC 通道注册代码、类型安全的ipcRenderer.invoke()声明防止主进程监听错事件名、渲染进程传参类型不匹配iOS (Swift)DeviceService.swift、DeviceInfo.swift、Codable协议实现替代手写URLSession请求、JSON 解析、NSError 处理以 iOS 为例我们的codegen-ios插件会读取 tRPC 路由定义自动生成 Swift 类// Generated: DeviceService.swift import Foundation public class DeviceService { public static func getDeviceInfo(deviceId: String, completion: escaping (ResultDeviceInfo, Error) - Void) { let url URL(string: http://localhost:3000/trpc/device.getDeviceInfo)! var request URLRequest(url: url) request.httpMethod POST request.setValue(application/json, forHTTPHeaderField: Content-Type) let body [input: [deviceId: deviceId]] request.httpBody try? JSONSerialization.data(withJSONObject: body) URLSession.shared.dataTask(with: request) { data, response, error in guard let data data else { completion(.failure(error ?? NetworkError.unknown)) return } do { let result try JSONDecoder().decode(TRPCResponseDeviceInfo.self, from: data) completion(.success(result.result)) } catch { completion(.failure(error)) } }.resume() } } // Generated: DeviceInfo.swift完全对应 Zod Schema public struct DeviceInfo: Codable { public let id: String public let name: String public let firmwareVersion: String public let battery: Int public let lastSeen: Date public let sensors: [Sensor] } public struct Sensor: Codable { public let type: SensorType public let value: Double public let unit: String } public enum SensorType: String, Codable { case temperature temperature case humidity humidity case pressure pressure }这套生成逻辑的价值在于iOS 开发者不需要懂 TypeScript 或 tRPC只需要调用DeviceService.getDeviceInfo就能获得完全类型安全的返回值。而且当后端新增字段Swift 侧重新运行codegen-ios所有模型类自动更新Xcode 会立刻标红所有未处理的新字段——这比人工阅读 API 文档再改代码效率提升至少 5 倍。3. 跨端落地实操从 Web 到 Electron 再到 iOS 的完整路径3.1 Web 端作为基础 UI 层与开发沙盒t3code 的 Web 端不是“最终产物”而是整个工作流的开发中枢与可视化沙盒。我们坚持“Web First”原则原因有三调试成本最低Chrome DevTools 的断点、性能分析、网络监控远超 Electron 或 iOS 模拟器迭代速度最快HMR热模块替换让 UI 修改秒级生效无需重启进程契约验证最准Web 端能 100% 覆盖 tRPC 接口的全部分支是后续平台移植的黄金标准。初始化 Web 项目时我们不使用create-t3-app社区模板而是手动搭建确保每一步都可控# 1. 创建 monorepo 根目录 pnpm init -y echo packages/* .gitignore # 2. 初始化 Web 子包Vite React TS mkdir packages/web cd packages/web pnpm create vitelatest . -- --template react-ts pnpm add tanstack/react-query trpc/react-query trpc/client zod # 3. 初始化服务端子包Express tRPC cd ../.. mkdir packages/server cd packages/server pnpm init -y pnpm add express trpc/server zod trpc/express # 4. 配置共享类型关键 mkdir packages/shared cd packages/shared pnpm init -y pnpm add zod # 创建 packages/shared/types.ts导出所有 Zod Schema注意packages/shared是整个 t3code 工作流的“心脏”。它不包含任何业务逻辑只存放 Zod Schema 和基础类型定义。所有子包web/server/ios都依赖它确保类型源头唯一。我们严禁在web包里写interface DeviceInfo { ... }所有类型必须从shared导入。Web 端的核心配置文件vite.config.ts需要特别处理 tRPC 开发代理// packages/web/vite.config.ts import { defineConfig } from vite; import react from vitejs/plugin-react; export default defineConfig({ plugins: [react()], server: { port: 5173, proxy: { /trpc: { target: http://localhost:3000, // 服务端端口 changeOrigin: true, secure: false, // 关键WebSocket 代理支持 tRPC 的订阅功能 ws: true, }, }, }, });这个配置让开发时 Web 端请求/trpc/xxx会自动转发到http://localhost:3000/trpc/xxx无需 CORS 设置。而生产构建时我们通过环境变量VITE_TRPC_URL切换为真实域名。3.2 Electron 端封装 Web 为桌面应用并注入系统能力Electron 是 t3code 跨端链条中最“重”的一环但它承担着不可替代的角色提供 Web 无法访问的系统级能力文件系统、硬件接口、通知、托盘菜单。我们不把 Electron 当作“套壳浏览器”而是将其视作一个增强型 WebView 容器 主进程服务总线。项目结构上Electron 应用与 Web 应用分离但共享 UIpackages/ ├── web/ # 纯 Web UI可独立部署 ├── electron/ # Electron 主进程 渲染进程入口 └── shared/ # 共享类型与工具Electron 渲染进程直接复用 Web 包的构建产物// packages/electron/main.ts import { app, BrowserWindow, ipcMain } from electron; import * as path from path; function createWindow() { const win new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, // ✅ 必须开启 nodeIntegration: false, // ✅ 必须关闭 sandbox: true, // ✅ 启用沙箱 }, }); // 加载 Web 构建产物dist 目录 if (app.isPackaged) { win.loadFile(path.join(__dirname, ../web/dist/index.html)); } else { win.loadURL(http://localhost:5173); // 开发时热加载 } }关键在preload.js—— 它是渲染进程与主进程通信的唯一可信通道// packages/electron/preload.js import { contextBridge, ipcRenderer } from electron; // ✅ 严格限制暴露给渲染进程的 API contextBridge.exposeInMainWorld(api, { // 仅暴露必要 IPC 方法 saveFile: (content: string, filename: string) ipcRenderer.invoke(file:save, content, filename), openDialog: () ipcRenderer.invoke(dialog:open), // ❌ 绝不暴露 require、process、fs 等 Node.js 全局对象 });然后在 Web UI 中调用// packages/web/src/components/ExportButton.tsx export function ExportButton() { const handleClick async () { try { // ✅ 类型安全的 IPC 调用 await window.api.saveFile(JSON.stringify(data), export.json); alert(导出成功); } catch (err) { console.error(err); } }; return button onClick{handleClick}导出数据/button; }实操心得Electron 的最大陷阱是nodeIntegration: true。我们团队曾因开启此选项导致 XSS 攻击链路打通——恶意网页脚本可通过require(child_process).exec(rm -rf /)删除用户文件。务必坚持contextIsolation: truesandbox: truepreload.js白名单模式。所有系统能力都必须经由ipcRenderer.invoke()走主进程代理这是安全底线。3.3 iOS 端用 WKWebView 承载 Web UI并桥接原生能力iOS 端是 t3code 工作流里最具挑战性的一环但也是价值最高的出口——让 Web UI 直接运行在 App Store 上架的应用中。我们不采用 Cordova 或 Capacitor而是基于WKWebViewWKScriptMessageHandler构建轻量桥接层原因很现实Cordova 插件生态陈旧iOS 16 的 ATSApp Transport Security策略导致大量插件失效Capacitor 的 Android/iOS 构建配置复杂CI/CD 流水线维护成本高而原生WKWebView性能更好、内存占用更低、苹果审核通过率更高无“过度依赖 Web 技术”风险。核心思路是Web UI 保持不变所有原生能力相机、定位、文件选择通过window.webkit.messageHandlers.xxx.postMessage()触发由 Swift 侧监听并执行。Swift 侧桥接代码精简版// AppDelegate.swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) - Bool { let config WKWebViewConfiguration() config.userContentController.add(self, name: nativeBridge) return true } // MARK: - WKScriptMessageHandler extension AppDelegate: WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let body message.body as? [String: Any] else { return } switch message.name { case camera:take: takePhoto(completion: { image in // 将图片转 Base64 发回 Web let base64 image.jpegData(compressionQuality: 0.8)?.base64EncodedString() ?? self.webView.evaluateJavaScript(window.nativeCallback(camera:take, \(base64))) { _, _ in } }) case file:select: selectFile(completion: { url in self.webView.evaluateJavaScript(window.nativeCallback(file:select, \(url.absoluteString))) { _, _ in } }) } } }Web 侧调用方式// packages/web/src/utils/nativeBridge.ts declare global { interface Window { webkit?: { messageHandlers: { nativeBridge: { postMessage: (message: any) void; }; }; }; nativeCallback?: (method: string, data: any) void; } } export function takePhoto(): Promisestring { return new Promise((resolve) { window.nativeCallback (method, data) { if (method camera:take) resolve(data); }; window.webkit?.messageHandlers.nativeBridge.postMessage({ method: camera:take }); }); }注意事项iOS 14 要求WKWebView的allowsInlineMediaPlayback和mediaTypesRequiringUserActionForPlayback必须正确设置否则视频无法自动播放App Store 审核时需在Info.plist中声明NSCameraUsageDescription等权限描述否则会被拒。我们把这些配置都封装进codegen-ios每次生成 Swift 代码时自动注入。3.4 打包与分发一次构建多端交付t3code 的终极目标是“写一次到处运行”但打包环节必须面对现实约束。我们采用分阶段构建策略平台构建命令输出产物分发方式Webpnpm build:webpackages/web/dist/Nginx / CDN / GitHub PagesElectronpnpm build:electronout/MyApp-darwin-arm64.zip,out/MyApp-win32-x64.zipGitHub Releases / 官网下载页iOSpnpm build:iosios/MyApp.xcarchiveXcode Organizer → Upload to App Store ConnectElectron 打包我们用electron-builder但做了关键定制// packages/electron/package.json { build: { appId: com.mycompany.myapp, productName: MyApp, files: [ **/*, !node_modules/**/*, !src/**/*, !test/**/* ], extraResources: [ { from: ../web/dist, to: resources/app/dist, filter: [**/*] } ], mac: { category: public.app-category.productivity, hardenedRuntime: true, gatekeeperAssess: false, entitlements: build/entitlements.mac.plist, provisioningProfile: build/provisioning_profile.mobileprovision } } }关键点extraResources将 Web 构建产物复制到 Electron 资源目录避免重复构建hardenedRuntime: true是 macOS Catalina 的强制要求否则无法安装entitlements.mac.plist必须包含com.apple.security.files.downloads.read等权限否则无法读写用户下载目录。iOS 打包则完全交给 Xcode但我们通过codegen-ios自动生成Info.plist和Entitlements.plist!-- Generated: Info.plist -- keyNSCameraUsageDescription/key string需要访问相机拍摄设备照片/string keyNSMicrophoneUsageDescription/key string需要访问麦克风录制音频/string keyNSPhotoLibraryUsageDescription/key string需要访问相册选择图片/string实操心得iOS 上架最大的坑不是技术而是证书与配置文件管理。我们团队用fastlane match统一管理所有证书每个成员只用fastlane match development就能拉取最新开发证书避免手动导出 P12 文件的混乱。另外CFBundleDisplayName必须与 App Store Connect 中的名称完全一致否则审核会被拒——这个校验点也被加入codegen-ios的预检脚本中。4. 常见问题与排查技巧实录踩过的坑比文档还多4.1 “Electron localhost 无法访问服务端”问题深度解析这是 t3code 新手最常遇到的“拦路虎”现象是Web 端在浏览器里能正常调用 tRPC 接口但 Electron 渲染进程中fetch(http://localhost:3000/trpc/xxx)报net::ERR_CONNECTION_REFUSED。表面看是网络问题实则是 Electron 的网络沙箱策略与开发服务器绑定地址的双重限制。根本原因有二开发服务器默认只监听127.0.0.1Vite/Express 启动时若未指定host: 0.0.0.0只接受本机 loopback 请求而 Electron 渲染进程的localhost解析可能受系统 hosts 文件影响Electron 的webSecurity默认开启即使服务端监听0.0.0.0Electron 也会阻止跨域请求尽管同是localhost但端口不同被视为跨域。解决方案分三步走第一步服务端绑定所有接口// packages/server/index.ts import express from express; import { applyWSSHandler } from trpc/server/adapters/ws; import { WebSocketServer } from ws; import { appRouter } from ./router; const app express(); const port 3000; // ✅ 关键监听 0.0.0.0 而非 127.0.0.1 app.listen(port, 0.0.0.0, () { console.log(Server running on http://localhost:${port}); }); // WebSocket 服务同样绑定 0.0.0.0 const wss new WebSocketServer({ port: 3001, host: 0.0.0.0 }); applyWSSHandler({ wss, router: appRouter });第二步Electron 渲染进程禁用 webSecurity仅开发环境// packages/electron/main.ts const win new BrowserWindow({ webPreferences: { // ⚠️ 仅开发时关闭生产必须移除 webSecurity: false, preload: path.join(__dirname, preload.js), }, });第三步Vite 代理配置强化// packages/web/vite.config.ts export default defineConfig({ server: { proxy: { /trpc: { target: http://127.0.0.1:3000, // ✅ 显式指定 127.0.0.1 changeOrigin: true, secure: false, ws: true, // ✅ 添加重写规则避免路径问题 rewrite: (path) path.replace(/^\/trpc/, /trpc), }, }, }, });排查技巧打开 Electron 的 DevTools执行fetch(http://127.0.0.1:3000/trpc/health)如果成功说明服务端没问题再执行fetch(http://localhost:3000/trpc/health)失败则证明是 DNS 解析问题——此时必须用127.0.0.1替代localhost。4.2 “iOS 端 WKWebView 白屏控制台无报错”问题根因与修复现象Xcode 运行 iOS AppWKWebView 加载index.html后一片空白Debug 控制台没有任何 JavaScript 错误Network 面板显示 HTML/CSS/JS 都 200 加载成功。这通常意味着CSPContent Security Policy拦截了内联脚本或 eval。t3code 模板默认使用 Vite 构建而 Vite 的index.html包含内联script标签如__vite__注入iOS 的 WKWebView 对 CSP 更严格默认阻止unsafe-inline和unsafe-eval。修复方法有二推荐后者方案一放宽 CSP不推荐!-- public/index.html -- meta http-equivContent-Security-Policy contentdefault-src self; script-src self unsafe-inline unsafe-eval;方案二禁用 Vite 的内联脚本注入推荐// packages/web/vite.config.ts export default defineConfig({ build: { // ✅ 关键禁用内联脚本所有代码输出为外部 .js 文件 rollupOptions: { output: { inlineDynamicImports: false, }, }, }, // ✅ 关键禁用 __vite__ 注入 define: { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, }, });然后在public/index.html中手动引入构建产物!DOCTYPE html html head meta charsetutf-8 / titlet3code/title /head body div idroot/div !-- ✅ 手动引入避免内联 -- script typemodule src/assets/index.[hash].js/script /body /html注意rollupOptions.output.inlineDynamicImports: false会让 Vite 把动态 import 也打包成独立文件增加 HTTP 请求数但换来的是 iOS 兼容性。我们测试过即使 20 个 chunk首屏时间也只增加 120msCDN 缓存后远低于白屏带来的用户体验损失。4.3 “tRPC 查询返回 undefined但服务端日志显示已执行”问题定位现象Web 端调用trpc.device.getDeviceInfo.useQuery({ deviceId })返回data: undefinedisLoading: true持续但服务端日志明确打印了查询结果。这几乎 100% 是tRPC 客户端配置的links链路中断。tRPC 的 query 默认使用httpBatchLink批量 HTTP 请求但如果服务端未正确配置createHTTPHandler或客户端url指向错误端口就会静默失败。排查步骤检查客户端链接配置// packages/web/src/trpc/client.ts import { createTRPCProxyClient, httpBatchLink } from trpc/client; import type { AppRouter } from ../server/router; export const trpc createTRPCProxyClientAppRouter({ links: [ httpBatchLink({ url: http://localhost:3000/trpc, // ✅ 确保端口与服务端一致 // ✅ 添加 fetch 配置捕获底层错误 fetch: (input, init) { console.log(tRPC fetch:, input, init); return fetch(input, init); }, }), ], });检查服务端路由挂载// packages/server/index.ts import { createExpressMiddleware } from trpc/server/adapters/express; import { appRouter } from ./router; app.use(/trpc, createExpressMiddleware({ router: appRouter })); // ✅ 必须挂载到 /trpc与客户端 url 一致抓包验证打开 Chrome DevTools → Network → Filtertrpc查看请求是否发出、响应状态码是否为 200、响应体是否为 JSON。如果请求根本没发出说明httpBatchLink初始化失败如果响应体是 HTML如 Express 默认 404 页面说明路由挂载路径错误。实操心得我们团队在trpc/client.ts中加入了一个debugLink在开发环境自动打印所有 tRPC 调用的入参、出参、耗时const debugLink () ({ async request({ op, next }) { console.time(tRPC ${op.type} ${op.path}); try { const result await next(op); console.timeEnd(tRPC ${op.type} ${op.path}); return result; } catch (err) { console.timeEnd(tRPC ${op.type

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑